@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.
- package/dist/index.d.ts +1035 -14
- package/dist/index.js +6867 -1884
- package/dist/index.js.map +1 -1
- package/package.json +65 -2
- package/src/client.ts +21 -0
- package/src/components/code-editor.tsx +398 -0
- package/src/components/desktop-viewer.tsx +647 -0
- package/src/components/diff-view.tsx +230 -0
- package/src/components/file-browser.tsx +838 -0
- package/src/components/message-timeline.tsx +70 -196
- package/src/components/pierre-diff.tsx +140 -0
- package/src/components/pierre-file.tsx +142 -0
- package/src/components/sandbox-files.tsx +509 -0
- package/src/components/sandbox-terminal.tsx +425 -0
- package/src/components/workspace-dock.tsx +247 -0
- package/src/hooks/use-desktop-stream.ts +214 -0
- package/src/hooks/use-sandbox-files.ts +670 -0
- package/src/hooks/use-sandbox-git.ts +105 -0
- package/src/hooks/use-sandbox-terminal.ts +226 -0
- package/src/hooks/use-session-capabilities.ts +415 -0
- package/src/hooks/use-terminal-stream.ts +207 -0
- package/src/index.ts +111 -2
- package/src/lib/cn.ts +20 -1
- package/src/lib/git-patch.ts +37 -0
- package/src/lib/use-theme-type.ts +40 -0
- package/src/lib/xterm-theme.ts +34 -0
- package/src/timeline/activity-rail.tsx +207 -0
- package/src/timeline/disclosure-context.tsx +34 -0
- package/src/timeline/index.ts +85 -0
- package/src/timeline/parsers.ts +248 -0
- package/src/{timeline.ts → timeline/projection.ts} +59 -134
- package/src/timeline/registry.ts +96 -0
- package/src/timeline/screenshot-lightbox.tsx +152 -0
- package/src/timeline/shared.tsx +481 -0
- package/src/timeline/tool-diff.tsx +91 -0
- package/src/timeline/tool-renderers.tsx +882 -0
- package/src/timeline/turn-summary.tsx +125 -0
- package/src/timeline/types.ts +131 -0
- package/src/types/external.d.ts +7 -0
- 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'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
|
+
}
|