@opengeni/react 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/index.d.ts +1035 -14
  2. package/dist/index.js +6867 -1884
  3. package/dist/index.js.map +1 -1
  4. package/package.json +65 -2
  5. package/src/client.ts +21 -0
  6. package/src/components/code-editor.tsx +398 -0
  7. package/src/components/desktop-viewer.tsx +647 -0
  8. package/src/components/diff-view.tsx +230 -0
  9. package/src/components/file-browser.tsx +838 -0
  10. package/src/components/message-timeline.tsx +70 -196
  11. package/src/components/pierre-diff.tsx +140 -0
  12. package/src/components/pierre-file.tsx +142 -0
  13. package/src/components/sandbox-files.tsx +509 -0
  14. package/src/components/sandbox-terminal.tsx +425 -0
  15. package/src/components/workspace-dock.tsx +247 -0
  16. package/src/hooks/use-desktop-stream.ts +214 -0
  17. package/src/hooks/use-sandbox-files.ts +670 -0
  18. package/src/hooks/use-sandbox-git.ts +105 -0
  19. package/src/hooks/use-sandbox-terminal.ts +226 -0
  20. package/src/hooks/use-session-capabilities.ts +415 -0
  21. package/src/hooks/use-terminal-stream.ts +207 -0
  22. package/src/index.ts +111 -2
  23. package/src/lib/cn.ts +20 -1
  24. package/src/lib/git-patch.ts +37 -0
  25. package/src/lib/use-theme-type.ts +40 -0
  26. package/src/lib/xterm-theme.ts +34 -0
  27. package/src/timeline/activity-rail.tsx +207 -0
  28. package/src/timeline/disclosure-context.tsx +34 -0
  29. package/src/timeline/index.ts +85 -0
  30. package/src/timeline/parsers.ts +248 -0
  31. package/src/{timeline.ts → timeline/projection.ts} +59 -134
  32. package/src/timeline/registry.ts +96 -0
  33. package/src/timeline/screenshot-lightbox.tsx +152 -0
  34. package/src/timeline/shared.tsx +481 -0
  35. package/src/timeline/tool-diff.tsx +91 -0
  36. package/src/timeline/tool-renderers.tsx +882 -0
  37. package/src/timeline/turn-summary.tsx +125 -0
  38. package/src/timeline/types.ts +131 -0
  39. package/src/types/external.d.ts +7 -0
  40. package/styles/index.css +72 -0
@@ -0,0 +1,647 @@
1
+ import type { CapabilityUnavailableReason, DesktopRfbFactory, DesktopStreamCapability } from "@opengeni/sdk";
2
+ import { LoaderCircleIcon, MonitorIcon, MousePointerClickIcon, WifiOffIcon } from "lucide-react";
3
+ import { type ReactNode, useEffect, useRef, useState } from "react";
4
+ import { cn } from "../lib/cn";
5
+ import { useDesktopStream } from "../hooks/use-desktop-stream";
6
+
7
+ export type DesktopViewerProps = {
8
+ /** The desktop cell of the negotiated capabilities (`capabilities.DesktopStream`). */
9
+ capability: DesktopStreamCapability | null;
10
+ /**
11
+ * Initial control mode. Default false (watch). When the user flips
12
+ * "Take control" the viewer drives input — but only if `capability.mode`
13
+ * permits it (server-gated; a read-only deployment disables the toggle).
14
+ * Pass a value to control it externally; omit to let the viewer own the state.
15
+ */
16
+ interactive?: boolean | undefined;
17
+ /** Render the built-in Watching ⇄ Take control toggle (default true). */
18
+ showControlToggle?: boolean | undefined;
19
+ scaleViewport?: boolean | undefined;
20
+ /** Custom RFB factory (tests / a WebRTC swap). Defaults to lazy @novnc/novnc. */
21
+ rfbFactory?: DesktopRfbFactory | undefined;
22
+ /**
23
+ * Consent gate for the un-redacted (and possibly shared) pixel plane. Rendered
24
+ * BEFORE connecting whenever the desktop requires acknowledgment that hasn't
25
+ * been given. Call `onAccept` to record consent (the host wires it to
26
+ * `client.acknowledgeStream` + a re-negotiate). When omitted, a default
27
+ * banner is shown.
28
+ */
29
+ renderConsentGate?: ((onAccept: () => void, shared: boolean) => ReactNode) | undefined;
30
+ /** Called when the default consent gate's accept button is pressed. */
31
+ onAcknowledge?: (() => void) | undefined;
32
+ /**
33
+ * Whether the host has the viewer attach engaged (i.e. the user has opted into
34
+ * watching — the parent's `watchDesktop`/`attachDesktop`). Drives the
35
+ * cold-state behaviour: when watching, a cold-but-warmable lease AUTO-WARMS
36
+ * (and re-warms when the box drains) instead of dead-ending. When omitted we
37
+ * infer it from a recorded consent (the default gate's accept), so the
38
+ * component still self-heals after the first acknowledgment.
39
+ */
40
+ watching?: boolean | undefined;
41
+ /**
42
+ * Request a (re)warm of the sandbox WITHOUT re-acknowledging (the consent has
43
+ * already been recorded — only the box drained). The host wires this to
44
+ * "engage the viewer attach + re-negotiate". Called automatically when a
45
+ * watched desktop is found cold-but-warmable, and behind the manual retry on
46
+ * the warming notice. Distinct from `onAcknowledge`, which is the FIRST,
47
+ * consent-bearing warm.
48
+ */
49
+ onWarm?: (() => void) | undefined;
50
+ /** Shown when transport is null (headless backend / degraded / disabled). */
51
+ renderUnavailable?: ((reason: CapabilityUnavailableReason | null) => ReactNode) | undefined;
52
+ /** Shown while the box is cold/warming (no live address yet). */
53
+ renderWarming?: (() => ReactNode) | undefined;
54
+ /** Shown when the per-session viewer cap (429) was hit. */
55
+ renderViewerCap?: (() => ReactNode) | undefined;
56
+ /** Surface the 429 cap state from `useSessionCapabilities().viewerCapReached`. */
57
+ viewerCapReached?: boolean | undefined;
58
+ /**
59
+ * Connect watchdog (ms): if a live url is present but the RFB hasn't connected
60
+ * within this window, surface a "Couldn't connect" + Reconnect instead of an
61
+ * eternal idle scrim. Default 13000. 0 disables the watchdog.
62
+ */
63
+ connectTimeoutMs?: number | undefined;
64
+ className?: string | undefined;
65
+ };
66
+
67
+ /**
68
+ * The derived desktop surface state. A single source of truth so the overlay,
69
+ * the scrim, the auto-warm effect, and the watchdog all agree. Priority order
70
+ * (highest first): viewer-cap → unavailable → consent → warming → connect-failed
71
+ * → error → connecting → connected.
72
+ */
73
+ type DesktopUiState =
74
+ | "viewer_cap" // 429 — the per-session live-viewer limit is reached.
75
+ | "unavailable" // genuinely unsupported (headless/policy/os/backend/not-provisioned).
76
+ | "consent" // un-redacted/shared plane needs acknowledgment first.
77
+ | "warming" // cold-but-warmable: box not running yet (we auto-warm + spin).
78
+ | "connecting" // url is live, RFB negotiating/handshaking (we spin, with a watchdog).
79
+ | "connect_failed" // watchdog fired: url present but never connected.
80
+ | "error" // RFB error / securityfailure after a connect.
81
+ | "connected"; // live framebuffer painting.
82
+
83
+ /** Reasons that are genuinely-unavailable (never warmable from the viewer). A
84
+ * `lease_cold` is deliberately EXCLUDED — that's the warmable cold state. */
85
+ function isHardUnavailable(reason: CapabilityUnavailableReason | null): boolean {
86
+ switch (reason) {
87
+ case "backend_unsupported":
88
+ case "os_unsupported":
89
+ case "not_provisioned":
90
+ case "disabled_by_policy":
91
+ case "tier_headless":
92
+ return true;
93
+ default:
94
+ // null or "lease_cold" → not a hard reason (cold-but-warmable / live).
95
+ return false;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * The desktop surface: a noVNC client connecting to the Channel-B scoped tunnel
101
+ * URL from the capability doc. Owns the mount `<div ref>`, drives
102
+ * `useDesktopStream` (SSR-safe lazy RFB), and renders a real
103
+ * cold → warming → connecting → connected → error state machine with live
104
+ * feedback (spinners, transitions) — never a dead black box with stale text.
105
+ *
106
+ * The read-only vs interactive decision is enforced server-first
107
+ * (`capability.mode`): when the deployment advertises mode "interactive" the
108
+ * viewer can TAKE CONTROL and drive the mouse & keyboard into the box's :0; a
109
+ * "read-only" deployment disables the take-control affordance (graceful, with a
110
+ * reason).
111
+ *
112
+ * Warming: a cold-but-warmable lease (`reason: "lease_cold"`) is NOT a dead end.
113
+ * When the user is watching (consented), the viewer asks the host to (re)warm
114
+ * the box (`onWarm`) and shows a "Warming…" spinner; if the box later drains to
115
+ * cold it re-warms. Genuinely-unavailable surfaces (headless/policy/os/backend)
116
+ * keep a clear, static unavailable notice.
117
+ */
118
+ export function DesktopViewer({
119
+ capability,
120
+ interactive,
121
+ showControlToggle = true,
122
+ scaleViewport,
123
+ rfbFactory,
124
+ renderConsentGate,
125
+ onAcknowledge,
126
+ watching,
127
+ onWarm,
128
+ renderUnavailable,
129
+ renderWarming,
130
+ renderViewerCap,
131
+ viewerCapReached,
132
+ connectTimeoutMs = 13_000,
133
+ className,
134
+ }: DesktopViewerProps) {
135
+ const containerRef = useRef<HTMLDivElement | null>(null);
136
+ const [consented, setConsented] = useState(false);
137
+ // Local control state when not externally controlled. The server gate
138
+ // (`capability.mode`) is the hard ceiling — a read-only deployment can never
139
+ // be flipped to interactive regardless of this toggle.
140
+ const [takeControl, setTakeControl] = useState(interactive ?? false);
141
+ const externallyControlled = interactive !== undefined;
142
+ const serverAllowsControl = capability?.mode !== "read-only";
143
+ const wantControl = externallyControlled ? interactive : takeControl;
144
+ const inControl = Boolean(wantControl) && serverAllowsControl;
145
+
146
+ // Whether the host has the viewer attach engaged. Prefer the explicit prop;
147
+ // fall back to a locally-recorded consent so the component still self-heals
148
+ // (auto-warms / re-warms) once the user has accepted the gate at least once.
149
+ const isWatching = watching ?? consented;
150
+
151
+ // ── Decide the rendered state (before touching the stream hook) ─────────────
152
+ const transportNull = !capability || capability.transport === null;
153
+ const reason = capability?.reason ?? null;
154
+ const needsAck =
155
+ capability?.requiresAcknowledgment === true && capability.acknowledged !== true && !consented;
156
+ // No live address yet on an otherwise-live transport (post-ack, mid-warm).
157
+ const noLiveAddress = Boolean(capability) && !transportNull && !capability!.url;
158
+
159
+ // Cold-but-warmable: the lease is cold (`lease_cold`) OR the transport is up
160
+ // but no url has been minted yet — either way warming, not unavailable.
161
+ const coldWarmable =
162
+ (transportNull && reason === "lease_cold") || (!transportNull && noLiveAddress);
163
+ // Genuinely-unavailable: transport null for a HARD reason (not lease_cold).
164
+ const hardUnavailable = transportNull && isHardUnavailable(reason);
165
+
166
+ const accept = () => {
167
+ setConsented(true);
168
+ onAcknowledge?.();
169
+ };
170
+
171
+ // Release control WITHOUT ever swallowing a key the desktop needs. Esc, and
172
+ // every other key, pass straight through to noVNC/:0 — vital for vim, menus,
173
+ // and dialogs inside the box. Exactly ONE non-trapping keyboard exit:
174
+ // • A single non-conflicting chord — Ctrl+Alt+Shift pressed on its own (no
175
+ // other key) — which no app binds, so it never eats a real keystroke.
176
+ // (Plus the always-visible "Return control" button in the in-control bar.)
177
+ //
178
+ // We deliberately DO NOT release on pointer-leave (or window blur): those fire
179
+ // as a SIDE EFFECT of connecting — the noVNC canvas re-laying-out, the surface
180
+ // grabbing focus, the connecting scrim swapping in — which bounced control
181
+ // straight back to "watch" the instant the user took it. Control is given up
182
+ // only on an explicit, intentional gesture (the button or the chord).
183
+ useEffect(() => {
184
+ if (!inControl || externallyControlled) return;
185
+ const onKey = (event: KeyboardEvent) => {
186
+ // Only the bare modifier chord releases; if any non-modifier key is also
187
+ // down (event.key is a real key like "a"/"Escape"), let it pass through.
188
+ if (
189
+ event.ctrlKey &&
190
+ event.altKey &&
191
+ event.shiftKey &&
192
+ (event.key === "Control" || event.key === "Alt" || event.key === "Shift")
193
+ ) {
194
+ event.preventDefault();
195
+ setTakeControl(false);
196
+ }
197
+ };
198
+ window.addEventListener("keydown", onKey);
199
+ return () => {
200
+ window.removeEventListener("keydown", onKey);
201
+ };
202
+ }, [inControl, externallyControlled]);
203
+
204
+ // The hook is always called (rules of hooks); it stays idle until `url` is set.
205
+ // Do NOT open a socket while the viewer-cap (429) notice is showing — the slot
206
+ // is already exhausted, so connecting would only burn a doomed attempt (and in
207
+ // tests leak an unhandled ws error from the never-resolving tunnel URL).
208
+ const connectCapability =
209
+ !transportNull && !needsAck && !viewerCapReached ? capability : null;
210
+ const stream = useDesktopStream({
211
+ capability: connectCapability,
212
+ containerRef,
213
+ interactive: inControl,
214
+ ...(scaleViewport !== undefined ? { scaleViewport } : {}),
215
+ ...(rfbFactory ? { rfbFactory } : {}),
216
+ });
217
+
218
+ const connected = stream.state === "connected";
219
+ const hasLiveUrl = Boolean(connectCapability?.url);
220
+ // The latest stream state, read without making it an effect dependency — so the
221
+ // re-attach below can consult it on a visibility change WITHOUT re-subscribing
222
+ // (and re-firing) every time the state walks idle→negotiating→connected.
223
+ const streamStateRef = useRef(stream.state);
224
+ streamStateRef.current = stream.state;
225
+
226
+ // ── AUTO-WARM ───────────────────────────────────────────────────────────────
227
+ // When the user is watching and the desktop is cold-but-warmable, ask the host
228
+ // to (re)warm the box. This covers BOTH (a) the user just accepted consent and
229
+ // (b) a previously-warm box that drained back to cold under a live viewer. We
230
+ // fire once per distinct cold episode (keyed on the lease epoch + url) so we
231
+ // don't spam the attach while a single warm is in flight. `onWarm` is the
232
+ // no-re-ack path; if the host only wired `onAcknowledge` (no `onWarm`), the
233
+ // first warm still rides consent and subsequent drains fall back to it.
234
+ const warmKeyRef = useRef<string | null>(null);
235
+ useEffect(() => {
236
+ if (!isWatching || !coldWarmable || needsAck || viewerCapReached) {
237
+ // Reset the de-dupe once we leave the cold episode so a future drain warms.
238
+ if (!coldWarmable) warmKeyRef.current = null;
239
+ return;
240
+ }
241
+ const warm = onWarm ?? onAcknowledge;
242
+ if (!warm) return;
243
+ // Key the de-dupe on the cell fields available to the viewer: while cold,
244
+ // `reason`/`url`/`expiresAt` are stable, so we warm exactly once; once the box
245
+ // warms the cell changes (url+expiresAt minted) and `coldWarmable` flips false,
246
+ // resetting the ref so a later drain re-warms.
247
+ const key = `${capability?.reason ?? ""}:${capability?.url ?? ""}:${capability?.expiresAt ?? ""}`;
248
+ if (warmKeyRef.current === key) return; // already kicked this episode.
249
+ warmKeyRef.current = key;
250
+ warm();
251
+ // capability identity is the trigger; leaseEpoch/expiresAt key the episode.
252
+ // eslint-disable-next-line react-hooks/exhaustive-deps
253
+ }, [isWatching, coldWarmable, needsAck, viewerCapReached, capability, onWarm, onAcknowledge]);
254
+
255
+ // ── TAB RE-ATTACH ───────────────────────────────────────────────────────────
256
+ // The "stuck Watching, never connects" bug: the RFB socket never (re)fires when
257
+ // the Desktop tab is shown without a refresh. When a live url exists but we are
258
+ // not connected/connecting, nudge the stream hook to (re)attach — on mount, on
259
+ // the document becoming visible, and on a fresh capability. Re-attach is cheap
260
+ // (disconnect-old/connect-new) and idempotent: the hook ignores it once live.
261
+ useEffect(() => {
262
+ if (!hasLiveUrl || typeof document === "undefined") return;
263
+ const maybeReattach = () => {
264
+ if (document.visibilityState !== "visible") return;
265
+ // Only kick a socket that never OPENED (idle — e.g. the tab was hidden when
266
+ // the url arrived, so the connect effect bailed on a missing container).
267
+ // Deliberately NOT on "error": that surfaces an overlay with an explicit
268
+ // Reconnect, and auto-retrying here would hammer (reconnect → error →
269
+ // reconnect…). We read the live state via a ref so this effect does NOT
270
+ // depend on `stream.state` — depending on it re-ran the effect on every
271
+ // transition and re-fired the kick, which is the reconnect loop.
272
+ if (streamStateRef.current === "idle") stream.reconnect();
273
+ };
274
+ // The connect effect already opens the socket on mount / a fresh url. The
275
+ // ONLY gap it can't self-heal is a tab that was hidden when the url arrived
276
+ // (container not in the DOM → connect effect bailed to idle): the socket then
277
+ // never (re)fires on its own. So we revive it on the tab becoming visible —
278
+ // NOT on mount (that double-opens the socket the connect effect just opened).
279
+ document.addEventListener("visibilitychange", maybeReattach);
280
+ return () => document.removeEventListener("visibilitychange", maybeReattach);
281
+ // Re-run ONLY on a fresh live url, never on a state transition.
282
+ // eslint-disable-next-line react-hooks/exhaustive-deps
283
+ }, [hasLiveUrl, connectCapability?.url]);
284
+
285
+ // ── CONNECT WATCHDOG ─────────────────────────────────────────────────────────
286
+ // If a live url is present but the RFB hasn't reached "connected" within the
287
+ // window, stop spinning forever and surface "Couldn't connect" + Reconnect.
288
+ // Cleared whenever we connect, the url rotates, or the user reconnects.
289
+ const [connectTimedOut, setConnectTimedOut] = useState(false);
290
+ // Bumped on every manual reconnect so the watchdog re-arms its window (the
291
+ // stream hook's url/error don't change on a reconnect, so we need our own key).
292
+ const [reconnectNonce, setReconnectNonce] = useState(0);
293
+ useEffect(() => {
294
+ // Re-arm on a fresh url / a reconnect and clear the moment we connect or hit a
295
+ // real RFB error. We deliberately do NOT key on `stream.state`, so the benign
296
+ // idle→connecting walk doesn't keep resetting the window — the full timeout
297
+ // runs from the url becoming live.
298
+ setConnectTimedOut(false);
299
+ if (!hasLiveUrl || connected || stream.error || connectTimeoutMs <= 0) return;
300
+ const timer = setTimeout(() => setConnectTimedOut(true), connectTimeoutMs);
301
+ return () => clearTimeout(timer);
302
+ }, [hasLiveUrl, connected, connectTimeoutMs, connectCapability?.url, stream.error, reconnectNonce]);
303
+
304
+ const reconnect = () => {
305
+ setConnectTimedOut(false);
306
+ setReconnectNonce((n) => n + 1);
307
+ stream.reconnect();
308
+ };
309
+
310
+ // ── Resolve the single UI state (priority order) ────────────────────────────
311
+ let uiState: DesktopUiState;
312
+ if (viewerCapReached) uiState = "viewer_cap";
313
+ else if (hardUnavailable) uiState = "unavailable";
314
+ else if (needsAck && !isWatching) uiState = "consent";
315
+ else if (coldWarmable) uiState = "warming";
316
+ else if (connected) uiState = "connected";
317
+ else if (stream.error) uiState = "error";
318
+ else if (connectTimedOut) uiState = "connect_failed";
319
+ else uiState = "connecting"; // hasLiveUrl && not yet connected (idle/negotiating/connecting).
320
+
321
+ // An overlay blocks the surface for every state except connected; the toggle
322
+ // and the live scrim are suppressed under an overlay.
323
+ const overlayShown = uiState !== "connected" && uiState !== "connecting";
324
+ const showToggle = showControlToggle && !overlayShown;
325
+
326
+ let overlay: ReactNode = null;
327
+ switch (uiState) {
328
+ case "viewer_cap":
329
+ overlay =
330
+ renderViewerCap?.() ??
331
+ defaultNotice("Too many viewers", "This session has reached its live-viewer limit. Try again shortly.");
332
+ break;
333
+ case "unavailable":
334
+ overlay =
335
+ renderUnavailable?.(reason) ?? defaultNotice("Desktop unavailable", unavailableCopy(reason));
336
+ break;
337
+ case "consent":
338
+ overlay = renderConsentGate ? (
339
+ renderConsentGate(accept, capability?.shared ?? false)
340
+ ) : (
341
+ <DefaultConsentGate shared={capability?.shared ?? false} onAccept={accept} />
342
+ );
343
+ break;
344
+ case "warming":
345
+ overlay = renderWarming?.() ?? <WarmingNotice />;
346
+ break;
347
+ case "connect_failed":
348
+ overlay = defaultNotice(
349
+ "Couldn’t connect",
350
+ "The desktop is warm but the live stream didn’t come up. This usually clears on a retry.",
351
+ reconnect,
352
+ );
353
+ break;
354
+ case "error":
355
+ overlay = defaultNotice("Desktop disconnected", stream.error?.message ?? "The stream dropped.", reconnect);
356
+ break;
357
+ case "connecting":
358
+ case "connected":
359
+ overlay = null;
360
+ break;
361
+ }
362
+
363
+ return (
364
+ <div
365
+ className={cn(
366
+ "relative h-full w-full overflow-hidden bg-black",
367
+ inControl &&
368
+ "ring-2 ring-inset ring-[color:var(--og-color-accent,var(--color-brand,#3b82f6))]",
369
+ className,
370
+ )}
371
+ data-opengeni-desktop
372
+ data-state={stream.state}
373
+ data-ui-state={uiState}
374
+ data-in-control={inControl ? "true" : undefined}
375
+ >
376
+ {/* noVNC mount target. It appends a `width:100%;height:100%` `_screen` div
377
+ and AUTOSCALES the 1280x800 framebuffer to fit THIS box. We pin it to
378
+ the bounded `relative` wrapper with `absolute inset-0` (not just
379
+ `h-full w-full`) so its measured size is ALWAYS the panel — never the
380
+ canvas content. A content-sized mount is exactly what makes noVNC
381
+ measure a huge screen and paint the desktop "zoomed in". `overflow-hidden`
382
+ keeps the centered (margin:auto) canvas from ever spilling the panel. */}
383
+ <div
384
+ ref={containerRef}
385
+ className="absolute inset-0 overflow-hidden"
386
+ data-opengeni-desktop-canvas
387
+ data-state={stream.state}
388
+ />
389
+
390
+ {/* Idle / connecting scrim: a quiet, ALIVE "connecting to the desktop"
391
+ state behind the canvas so the surface never reads as a dead black
392
+ rectangle before the first framebuffer paints. Only shown in the
393
+ `connecting` UI-state (every other non-connected state has an explicit
394
+ overlay). A spinner + transitional copy makes it feel live. */}
395
+ {uiState === "connecting" && (
396
+ <div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
397
+ <span className="relative flex items-center justify-center">
398
+ <MonitorIcon className="size-8 opacity-30" strokeWidth={1.5} />
399
+ <LoaderCircleIcon
400
+ className="absolute size-12 animate-og-spin text-[color:var(--og-color-accent,var(--color-brand,#3b82f6))] opacity-70"
401
+ strokeWidth={1.25}
402
+ />
403
+ </span>
404
+ <span className="text-xs">Connecting to the desktop…</span>
405
+ </div>
406
+ )}
407
+
408
+ {/* Take-control affordance. Two distinct states:
409
+ - WATCHING → a prominent, centered call-to-action button overlaid on
410
+ the desktop (the primary CTA; tasteful so the screen stays visible).
411
+ - IN CONTROL → a small top bar ("You're in control · click Return
412
+ control (or Ctrl+Alt+Shift)") with a clearly-visible Return-control
413
+ button; the desktop stays fully usable and EVERY key — Esc included —
414
+ passes through to the box. Server-gated: when the deployment is
415
+ read-only the CTA renders disabled with a reason so it degrades
416
+ gracefully. */}
417
+ {showToggle && !externallyControlled && inControl && (
418
+ <InControlBar
419
+ shared={capability?.shared ?? false}
420
+ onRelease={() => setTakeControl(false)}
421
+ />
422
+ )}
423
+ {/* The big CTA only appears once the framebuffer is live + the viewer is
424
+ watching: before connect the scrim already communicates state, so we
425
+ don't double up. A read-only deployment (serverAllowsControl=false) still
426
+ surfaces the CTA disabled-with-reason once connected, so it degrades
427
+ gracefully and stays discoverable. */}
428
+ {showToggle && !externallyControlled && !inControl && connected && (
429
+ <TakeControlCallToAction
430
+ disabled={!serverAllowsControl}
431
+ disabledReason={
432
+ !serverAllowsControl ? "This deployment streams the desktop read-only" : undefined
433
+ }
434
+ onTakeControl={() => setTakeControl(true)}
435
+ />
436
+ )}
437
+
438
+ {overlay && (
439
+ <div className="absolute inset-0 flex items-center justify-center p-4">{overlay}</div>
440
+ )}
441
+ </div>
442
+ );
443
+ }
444
+
445
+ /**
446
+ * The primary WATCHING-state call-to-action: a large, centered pill button
447
+ * overlaid on the desktop inviting the viewer to drive the mouse & keyboard.
448
+ * Tasteful by default — it sits in the LOWER-center with a soft scrim only behind
449
+ * the button (not the whole screen) and lifts on hover, so a watcher can still see
450
+ * the agent work. Server-gated: when the deployment is read-only (or the desktop
451
+ * hasn't connected yet) it renders disabled with a reason. Accessible: a real
452
+ * <button> with a title, focus ring, and Enter/Space activation.
453
+ */
454
+ function TakeControlCallToAction({
455
+ disabled,
456
+ disabledReason,
457
+ onTakeControl,
458
+ }: {
459
+ disabled: boolean;
460
+ disabledReason?: string | undefined;
461
+ onTakeControl: () => void;
462
+ }) {
463
+ return (
464
+ // The wrapper spans the surface but is click-through (pointer-events-none); only
465
+ // the button itself is interactive, so watchers can still see the desktop.
466
+ <div className="pointer-events-none absolute inset-0 flex items-end justify-center pb-[8%]">
467
+ <button
468
+ type="button"
469
+ disabled={disabled}
470
+ aria-label="Take control of the desktop"
471
+ title={disabled ? disabledReason : "Take control of the desktop"}
472
+ onClick={onTakeControl}
473
+ className={cn(
474
+ "group pointer-events-auto flex items-center gap-3 rounded-[var(--og-radius-lg,12px)] border px-5 py-3",
475
+ "border-[color:var(--og-color-border,var(--color-border,#2a2a2a))]",
476
+ "bg-[color:var(--og-color-bg,#0d0d0d)]/85 backdrop-blur-md",
477
+ "shadow-[var(--og-shadow-lg,0_10px_30px_-10px_rgba(0,0,0,0.6))]",
478
+ "outline-none transition-all duration-150 ease-out",
479
+ "focus-visible:ring-2 focus-visible:ring-[color:var(--og-color-accent,var(--color-brand,#3b82f6))] focus-visible:ring-offset-2 focus-visible:ring-offset-black",
480
+ disabled
481
+ ? "cursor-not-allowed opacity-60"
482
+ : cn(
483
+ "cursor-pointer opacity-90 hover:-translate-y-0.5 hover:opacity-100",
484
+ "hover:border-[color:var(--og-color-accent,var(--color-brand,#3b82f6))]",
485
+ "hover:bg-[color:var(--og-color-accent,var(--color-brand,#3b82f6))]/10",
486
+ ),
487
+ )}
488
+ >
489
+ <span
490
+ className={cn(
491
+ "flex size-9 shrink-0 items-center justify-center rounded-full transition-colors",
492
+ "bg-[color:var(--og-color-accent,var(--color-brand,#3b82f6))]",
493
+ "text-[color:var(--og-color-accent-fg,#fff)]",
494
+ disabled ? "" : "group-hover:scale-105",
495
+ )}
496
+ >
497
+ <MousePointerClickIcon className="size-5" strokeWidth={2} />
498
+ </span>
499
+ <span className="flex flex-col items-start leading-tight">
500
+ <span className="text-sm font-semibold text-[color:var(--og-color-fg,#e6e6e6)]">
501
+ Take control
502
+ </span>
503
+ <span className="text-[11px] text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
504
+ {disabled && disabledReason ? disabledReason : "Drive the mouse & keyboard"}
505
+ </span>
506
+ </span>
507
+ </button>
508
+ </div>
509
+ );
510
+ }
511
+
512
+ /**
513
+ * The IN-CONTROL state: a small top bar so the desktop stays fully usable while
514
+ * driving (the accent ring around the viewport carries the primary "you're
515
+ * driving" signal). Every keystroke — including Esc — passes through to the box,
516
+ * so release lives only on explicit, non-trapping affordances: a clearly-visible
517
+ * "Return control" button and the Ctrl+Alt+Shift chord. The button is solid
518
+ * (not a faint ghost) so it's always discoverable as the way out.
519
+ */
520
+ function InControlBar({ shared, onRelease }: { shared: boolean; onRelease: () => void }) {
521
+ return (
522
+ <div className="pointer-events-none absolute inset-x-0 top-0 flex items-center justify-between gap-2 p-2">
523
+ <span className="pointer-events-auto inline-flex items-center gap-2 rounded-[var(--og-radius-sm,4px)] bg-[color:var(--og-color-accent,var(--color-brand,#3b82f6))] px-2.5 py-1 text-[11px] font-medium text-[color:var(--og-color-accent-fg,#fff)] shadow-[var(--og-shadow-md)]">
524
+ <span className="size-1.5 animate-pulse rounded-full bg-current" aria-hidden />
525
+ You&apos;re in control
526
+ <span className="opacity-75">· click Return control (or Ctrl+Alt+Shift)</span>
527
+ </span>
528
+ <div className="pointer-events-auto flex items-center gap-1.5">
529
+ {shared && (
530
+ <span className="rounded-[var(--og-radius-sm,4px)] bg-[color:var(--og-color-danger,var(--color-danger,#f85149))]/85 px-2 py-0.5 text-[10px] text-white">
531
+ Shared box — others are watching
532
+ </span>
533
+ )}
534
+ <button
535
+ type="button"
536
+ onClick={onRelease}
537
+ title="Return control (or press Ctrl+Alt+Shift)"
538
+ className={cn(
539
+ "inline-flex items-center gap-1.5 rounded-[var(--og-radius-sm,4px)] border px-2.5 py-1 text-[11px] font-semibold shadow-[var(--og-shadow-md)] transition-colors",
540
+ "border-[color:var(--og-color-accent,var(--color-brand,#3b82f6))] bg-[color:var(--og-color-bg,#0d0d0d)]/90 text-[color:var(--og-color-fg,#e6e6e6)] backdrop-blur-sm",
541
+ "outline-none hover:bg-[color:var(--og-color-accent,var(--color-brand,#3b82f6))] hover:text-[color:var(--og-color-accent-fg,#fff)] focus-visible:ring-2 focus-visible:ring-[color:var(--og-color-accent,var(--color-brand,#3b82f6))]",
542
+ )}
543
+ >
544
+ <MonitorIcon className="size-3.5" strokeWidth={2} aria-hidden />
545
+ Return control
546
+ </button>
547
+ </div>
548
+ </div>
549
+ );
550
+ }
551
+
552
+ function unavailableCopy(reason: CapabilityUnavailableReason | null): string {
553
+ switch (reason) {
554
+ case "backend_unsupported":
555
+ return "This sandbox backend cannot stream a desktop.";
556
+ case "tier_headless":
557
+ return "This deployment is headless — terminal, files, and diff only.";
558
+ case "os_unsupported":
559
+ return "The sandbox OS does not support a desktop stream.";
560
+ case "not_provisioned":
561
+ return "No display stack is provisioned on this box yet.";
562
+ case "disabled_by_policy":
563
+ return "Desktop streaming is disabled on this deployment.";
564
+ case "lease_cold":
565
+ // Should not reach the unavailable notice (lease_cold warms), but keep
566
+ // friendly copy as a fallback rather than the old dead-end wording.
567
+ return "Waiting for the sandbox to start…";
568
+ default:
569
+ return "The desktop isn’t available for this sandbox.";
570
+ }
571
+ }
572
+
573
+ /**
574
+ * The WARMING state: a cold-but-warmable box that is being spun up. A spinner +
575
+ * clear, accurate copy — and (when the host wired a manual warm) an explicit
576
+ * retry so a slow warm never feels stuck. This is the cure for the old
577
+ * "Desktop unavailable — Start a turn or attach to warm it" dead-end: the box IS
578
+ * being warmed automatically; we just tell the user it's happening.
579
+ */
580
+ function WarmingNotice({ onRetry }: { onRetry?: (() => void) | undefined }) {
581
+ return (
582
+ <div className="flex max-w-sm flex-col items-center gap-3 rounded-lg border border-[color:var(--color-border,#2a2a2a)] bg-[color:var(--color-bg,#0d0d0d)]/90 p-5 text-center text-sm text-[color:var(--color-fg,#e6e6e6)] backdrop-blur-sm">
583
+ <LoaderCircleIcon
584
+ className="size-7 animate-og-spin text-[color:var(--og-color-accent,var(--color-brand,#3b82f6))]"
585
+ strokeWidth={1.5}
586
+ />
587
+ <div className="space-y-1">
588
+ <div className="font-medium">Warming the sandbox…</div>
589
+ <p className="text-xs text-[color:var(--color-fg-subtle,#888)]">
590
+ Spinning up the desktop — this takes a few seconds.
591
+ </p>
592
+ </div>
593
+ {onRetry && (
594
+ <button
595
+ type="button"
596
+ onClick={onRetry}
597
+ className="rounded border border-[color:var(--color-border,#2a2a2a)] px-3 py-1.5 text-xs text-[color:var(--color-fg-muted,#aaa)] transition-colors hover:text-[color:var(--color-fg,#e6e6e6)]"
598
+ >
599
+ Taking too long? Retry
600
+ </button>
601
+ )}
602
+ </div>
603
+ );
604
+ }
605
+
606
+ function DefaultConsentGate({ shared, onAccept }: { shared: boolean; onAccept: () => void }) {
607
+ return (
608
+ <div className="max-w-sm rounded-lg border border-[color:var(--color-border,#2a2a2a)] bg-[color:var(--color-bg,#0d0d0d)] p-4 text-center text-sm text-[color:var(--color-fg,#e6e6e6)]">
609
+ <div className="mb-1 font-medium">Watch the live desktop?</div>
610
+ <p className="mb-3 text-xs text-[color:var(--color-fg-subtle,#888)]">
611
+ The desktop pixel stream is <strong>un-redacted</strong> — it can show secrets the agent prints
612
+ on screen.
613
+ {shared
614
+ ? " This box is shared: you will also see sibling sessions' agents on the same screen."
615
+ : ""}
616
+ </p>
617
+ <button
618
+ type="button"
619
+ onClick={onAccept}
620
+ className="rounded bg-[color:var(--color-brand,#3b82f6)] px-3 py-1.5 text-xs font-medium text-white"
621
+ >
622
+ I understand — show the desktop
623
+ </button>
624
+ </div>
625
+ );
626
+ }
627
+
628
+ function defaultNotice(title: string, body: string, onRetry?: () => void): ReactNode {
629
+ return (
630
+ <div className="max-w-sm rounded-lg border border-[color:var(--color-border,#2a2a2a)] bg-[color:var(--color-bg,#0d0d0d)] p-4 text-center text-sm text-[color:var(--color-fg,#e6e6e6)]">
631
+ <div className="mb-1 flex items-center justify-center gap-1.5 font-medium">
632
+ {onRetry && <WifiOffIcon className="size-4 opacity-70" strokeWidth={1.75} />}
633
+ {title}
634
+ </div>
635
+ <p className="text-xs text-[color:var(--color-fg-subtle,#888)]">{body}</p>
636
+ {onRetry && (
637
+ <button
638
+ type="button"
639
+ onClick={onRetry}
640
+ className="mt-3 rounded border border-[color:var(--color-border,#2a2a2a)] px-3 py-1.5 text-xs transition-colors hover:border-[color:var(--og-color-accent,var(--color-brand,#3b82f6))]"
641
+ >
642
+ Reconnect
643
+ </button>
644
+ )}
645
+ </div>
646
+ );
647
+ }