@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,415 @@
1
+ import {
2
+ OpenGeniApiError,
3
+ applyUrlRotation,
4
+ type SessionCapabilities,
5
+ type SessionEvent,
6
+ type StreamUrlRotatedPayload,
7
+ } from "@opengeni/sdk";
8
+ import { useCallback, useEffect, useRef, useState } from "react";
9
+ import { useOpenGeni, type ClientOverride } from "../provider";
10
+
11
+ export type SessionCapabilitiesState = "idle" | "negotiating" | "ready" | "cold" | "error";
12
+
13
+ export type UseSessionCapabilitiesOptions = ClientOverride & {
14
+ /**
15
+ * Live event log to fold `stream.url.rotated` from (usually
16
+ * `useSessionEvents().events`). When present the desktop socket stays fresh on
17
+ * a box rollover without a round-trip; stale-epoch rotations are dropped.
18
+ */
19
+ events?: SessionEvent[] | undefined;
20
+ /**
21
+ * Whether to acquire a viewer holder for the desktop pixel plane. Requires the
22
+ * un-redacted acknowledgment to have been recorded (else the attach 409s and
23
+ * the hook surfaces the consent requirement). Default false: read-only
24
+ * negotiation (no holder, no warm) — terminal/files/git work without it.
25
+ */
26
+ attachDesktop?: boolean | undefined;
27
+ /**
28
+ * Whether to acquire a viewer holder to warm the box for the REAL interactive
29
+ * terminal (the ttyd pty-ws plane). Symmetric with `attachDesktop` and shares
30
+ * the SAME viewer attach (one warm box serves both planes), but needs NO
31
+ * un-redacted acknowledgment — a shell is interactive by nature, and the gate
32
+ * is the scoped tunnel URL + stream token. Default false: the terminal stays on
33
+ * the read-only Channel-A firehose until the user opens/focuses it. The attach
34
+ * folds the minted `pty-ws` url+token into the `Terminal` cell.
35
+ */
36
+ attachTerminal?: boolean | undefined;
37
+ /**
38
+ * Whether to acquire a viewer holder purely to KEEP THE BOX WARM while the
39
+ * structured Files surface is open. Shares the SAME viewer attach as the
40
+ * desktop/terminal (one warm box, one holder) and — like the terminal — needs
41
+ * NO un-redacted acknowledgment: listing/reading/writing files is the ordinary
42
+ * Channel-A control plane, not the pixel plane. Default false: the Files tab
43
+ * negotiates read-only and each op pays the cold-box resume (~5s). When true,
44
+ * the holder warms the box once and heartbeats it, so subsequent fs ops are
45
+ * ~100ms instead of re-resuming the box on every list/write. It folds NO live
46
+ * URL (files ride the stateless HTTP plane) — it only refcounts liveness.
47
+ */
48
+ attachFiles?: boolean | undefined;
49
+ /** Hold off negotiating (e.g. the workbench panel is collapsed). Default true. */
50
+ enabled?: boolean | undefined;
51
+ /** Poll cadence (ms) while the lease is cold/warming. Default 1500. */
52
+ warmingPollMs?: number | undefined;
53
+ /**
54
+ * Give up waiting for `warm` after this long while polling (ms) and surface a
55
+ * stalled error with a manual `renegotiate`. Default 30000 (must agree with
56
+ * the lease warming TTL — I15). 0 disables the deadline.
57
+ */
58
+ warmingDeadlineMs?: number | undefined;
59
+ };
60
+
61
+ export type UseSessionCapabilitiesResult = {
62
+ /** The negotiated capability doc — the single source of UI truth. */
63
+ capabilities: SessionCapabilities | null;
64
+ state: SessionCapabilitiesState;
65
+ error: Error | null;
66
+ /**
67
+ * 409 from the desktop attach: the un-redacted (or shared) plane needs explicit
68
+ * acknowledgment before a viewer holder is granted. Drives the consent prompt.
69
+ */
70
+ acknowledgmentRequired: "unredacted" | "shared" | null;
71
+ /** 429 from the desktop attach: the per-session viewer cap is reached. */
72
+ viewerCapReached: boolean;
73
+ /** The viewer holder id minted on a desktop attach (for detach/heartbeat). */
74
+ viewerId: string | null;
75
+ /** Force a re-negotiation (after acknowledging, a resolution change, etc.). */
76
+ renegotiate: () => void;
77
+ };
78
+
79
+ /**
80
+ * Whether a desktop attach (POST /viewers) is worth attempting for this cell.
81
+ * The desktop is FEASIBLE — and thus worth warming — when it already has a live
82
+ * transport, OR when the only thing missing is a warm box (a cold/un-provisioned
83
+ * lease). The handshake never warms a box, so a feasible-but-cold cell reports
84
+ * transport:null with a transient reason; the viewer attach is exactly what warms
85
+ * it. A genuinely-unsupported desktop (backend/os/policy/headless) is never
86
+ * attachable — attaching would 409/403/no-op without ever producing a stream.
87
+ */
88
+ function desktopAttachable(cell: SessionCapabilities["DesktopStream"]): boolean {
89
+ if (cell.transport !== null) return true;
90
+ // transport === null: attach only when the reason is a transient cold state.
91
+ return cell.reason === "lease_cold" || cell.reason === "not_provisioned" || cell.reason === null;
92
+ }
93
+
94
+ /**
95
+ * Whether warming the box for the interactive terminal (pty-ws) is worth it.
96
+ * The Terminal cell is ALWAYS feasible when the backend advertises a transport at
97
+ * all (`sse-events` on a cold box, `pty-ws` once warm) — only a genuinely
98
+ * terminal-less backend reports transport:null with a hard reason. The attach is
99
+ * what flips `sse-events` → `pty-ws` by warming the box and minting the ttyd
100
+ * tunnel URL, so we attach whenever the terminal is not hard-unavailable.
101
+ */
102
+ function terminalAttachable(cell: SessionCapabilities["Terminal"]): boolean {
103
+ if (cell.transport === null) {
104
+ // No terminal at all unless the only blocker is a transient cold state.
105
+ return cell.reason === "lease_cold" || cell.reason === "not_provisioned" || cell.reason === null;
106
+ }
107
+ // Already pty-ws (warm) is fine to (re)attach; sse-events is the cold state the
108
+ // attach upgrades. Either way it's attachable.
109
+ return true;
110
+ }
111
+
112
+ /** Read `stream.url.rotated` payloads off the live event log, newest last. */
113
+ function rotationsFrom(events: SessionEvent[]): StreamUrlRotatedPayload[] {
114
+ const out: StreamUrlRotatedPayload[] = [];
115
+ for (const event of events) {
116
+ if (event.type === "stream.url.rotated" && event.payload && typeof event.payload === "object") {
117
+ out.push(event.payload as StreamUrlRotatedPayload);
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /**
124
+ * The capability-negotiation hook. Discovers what THIS session+backend+OS
125
+ * supports (FileSystem/Terminal/Git always-ish; DesktopStream/Recording
126
+ * sometimes), drives capability-gated rendering, and — when `attachDesktop` —
127
+ * holds a viewer lease + heartbeats it so the box stays warm while watched.
128
+ *
129
+ * Degradation is a value, never a crash: an unsupported surface comes back
130
+ * `available:false`/`transport:null` + a `reason`; the components render the
131
+ * reason-aware empty state. 409 (consent) and 429 (viewer cap) are surfaced as
132
+ * typed signals, not thrown.
133
+ */
134
+ export function useSessionCapabilities(
135
+ sessionId: string | null | undefined,
136
+ options: UseSessionCapabilitiesOptions = {},
137
+ ): UseSessionCapabilitiesResult {
138
+ const { client, workspaceId } = useOpenGeni(options);
139
+ const enabled = (options.enabled ?? true) && Boolean(sessionId);
140
+ const attachDesktop = options.attachDesktop ?? false;
141
+ const attachTerminal = options.attachTerminal ?? false;
142
+ const attachFiles = options.attachFiles ?? false;
143
+ const warmingPollMs = options.warmingPollMs ?? 1500;
144
+ const warmingDeadlineMs = options.warmingDeadlineMs ?? 30_000;
145
+
146
+ const [capabilities, setCapabilities] = useState<SessionCapabilities | null>(null);
147
+ const [state, setState] = useState<SessionCapabilitiesState>("idle");
148
+ const [error, setError] = useState<Error | null>(null);
149
+ const [acknowledgmentRequired, setAcknowledgmentRequired] = useState<"unredacted" | "shared" | null>(null);
150
+ const [viewerCapReached, setViewerCapReached] = useState(false);
151
+ const [viewerId, setViewerId] = useState<string | null>(null);
152
+ // Bumped to force a fresh negotiation cycle.
153
+ const [nonce, setNonce] = useState(0);
154
+
155
+ // The epoch the client has settled on — used to fence stale rotations folded
156
+ // from the event log, and echoed on heartbeats.
157
+ const epochRef = useRef(0);
158
+ const viewerIdRef = useRef<string | null>(null);
159
+
160
+ const renegotiate = useCallback(() => {
161
+ setNonce((n) => n + 1);
162
+ }, []);
163
+
164
+ // ── Negotiation + viewer lifecycle ──────────────────────────────────────────
165
+ useEffect(() => {
166
+ if (!enabled || !sessionId) {
167
+ setState("idle");
168
+ setCapabilities(null);
169
+ return;
170
+ }
171
+ let cancelled = false;
172
+ let pollTimer: ReturnType<typeof setTimeout> | null = null;
173
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
174
+ let localViewerId: string | null = null;
175
+ const startedAt = Date.now();
176
+
177
+ const clearTimers = () => {
178
+ if (pollTimer !== null) clearTimeout(pollTimer);
179
+ if (heartbeatTimer !== null) clearInterval(heartbeatTimer);
180
+ pollTimer = null;
181
+ heartbeatTimer = null;
182
+ };
183
+
184
+ const settle = (caps: SessionCapabilities) => {
185
+ if (cancelled) return;
186
+ epochRef.current = caps.leaseEpoch;
187
+ setCapabilities(caps);
188
+ setError(null);
189
+ };
190
+
191
+ const startHeartbeat = (caps: SessionCapabilities) => {
192
+ if (!localViewerId || heartbeatTimer !== null) return;
193
+ const intervalMs = caps.viewerHeartbeatIntervalMs > 0 ? caps.viewerHeartbeatIntervalMs : 30_000;
194
+ heartbeatTimer = setInterval(() => {
195
+ if (cancelled || !localViewerId) return;
196
+ void client
197
+ .heartbeatViewer(workspaceId, sessionId, localViewerId, { leaseEpoch: epochRef.current })
198
+ .then((res) => {
199
+ // alive:false ⇒ the holder was reaped or the epoch moved under us;
200
+ // re-negotiate to re-acquire against the new owner.
201
+ if (!res.alive && !cancelled) {
202
+ renegotiate();
203
+ }
204
+ })
205
+ .catch((cause) => {
206
+ if (cancelled) return;
207
+ if (cause instanceof OpenGeniApiError && (cause.status === 409 || cause.status === 410)) {
208
+ renegotiate();
209
+ }
210
+ });
211
+ }, intervalMs);
212
+ };
213
+
214
+ const pollUntilWarm = () => {
215
+ pollTimer = setTimeout(() => {
216
+ if (cancelled) return;
217
+ void client
218
+ .getStreamCapabilities(workspaceId, sessionId)
219
+ .then((caps) => {
220
+ if (cancelled) return;
221
+ settle(caps);
222
+ if (caps.liveness === "warm" || caps.liveness === "draining") {
223
+ setState("ready");
224
+ return;
225
+ }
226
+ if (warmingDeadlineMs > 0 && Date.now() - startedAt > warmingDeadlineMs) {
227
+ setState("error");
228
+ setError(new Error("sandbox did not warm in time — retry to re-negotiate"));
229
+ return;
230
+ }
231
+ pollUntilWarm();
232
+ })
233
+ .catch((cause) => {
234
+ if (!cancelled) {
235
+ setState("error");
236
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
237
+ }
238
+ });
239
+ }, warmingPollMs);
240
+ };
241
+
242
+ void (async () => {
243
+ setState("negotiating");
244
+ setAcknowledgmentRequired(null);
245
+ setViewerCapReached(false);
246
+ try {
247
+ const caps = await client.getStreamCapabilities(workspaceId, sessionId);
248
+ if (cancelled) return;
249
+ settle(caps);
250
+
251
+ // Optional viewer attach: acquire a viewer holder (warms a cold box). ONE
252
+ // attach serves BOTH live planes — the desktop pixel stream AND the
253
+ // interactive terminal (ttyd pty-ws) ride the same warm box and the same
254
+ // holder; the response folds the minted address for each requested plane.
255
+ // The consent gate (409) and the viewer cap (429) surface as typed signals
256
+ // rather than throwing — the tabs degrade gracefully.
257
+ //
258
+ // KEY: the handshake (getStreamCapabilities) NEVER spins up a cold box, so
259
+ // on a cold/warming lease the desktop cell comes back transport:null and
260
+ // the terminal cell comes back `sse-events` (read-only firehose) with a
261
+ // transient reason (`lease_cold`/`not_provisioned`). Gating the attach on
262
+ // `transport` therefore dead-ends both surfaces (forever "Starting…"): they
263
+ // never warm because they never attach, and never attach because they
264
+ // aren't warm. We attach whenever a REQUESTED plane is FEASIBLE — transport
265
+ // already live OR cold-but-feasible — and let POST /viewers warm the box and
266
+ // mint the URLs. Only a genuinely-unsupported reason suppresses the attach.
267
+ const wantDesktopAttach = attachDesktop && desktopAttachable(caps.DesktopStream);
268
+ const wantTerminalAttach = attachTerminal && terminalAttachable(caps.Terminal);
269
+ // The Files surface warms the box for fast Channel-A ops. It folds no live
270
+ // URL (files are stateless HTTP) — the attach is purely a liveness refcount,
271
+ // so it's wanted whenever the FileSystem capability is advertised at all.
272
+ const wantFilesAttach = attachFiles && caps.FileSystem.available;
273
+ if (wantDesktopAttach || wantTerminalAttach || wantFilesAttach) {
274
+ try {
275
+ // Declare WHICH plane we're attaching for. `desktop:true` opts into the
276
+ // un-redacted pixel plane (which carries the consent gate); a
277
+ // terminal-only attach (`desktop:false`) warms the box + mints the
278
+ // pty-ws terminal cell WITHOUT tripping the desktop consent 409.
279
+ const holder = await client.attachViewer(workspaceId, sessionId, { desktop: wantDesktopAttach });
280
+ if (cancelled) return;
281
+ localViewerId = holder.viewerId;
282
+ viewerIdRef.current = holder.viewerId;
283
+ setViewerId(holder.viewerId);
284
+ epochRef.current = holder.leaseEpoch;
285
+ // Fold the freshly-minted live address(es) into the doc the components
286
+ // read. Desktop fields fold only when a desktop attach was wanted;
287
+ // terminal fields fold the minted ttyd pty-ws url+token when present.
288
+ setCapabilities((prev) =>
289
+ prev
290
+ ? {
291
+ ...prev,
292
+ liveness: holder.liveness,
293
+ leaseEpoch: holder.leaseEpoch,
294
+ viewerHeartbeatIntervalMs: holder.viewerHeartbeatIntervalMs,
295
+ DesktopStream: wantDesktopAttach
296
+ ? {
297
+ ...prev.DesktopStream,
298
+ transport: holder.transport ?? prev.DesktopStream.transport,
299
+ client: holder.client ?? prev.DesktopStream.client,
300
+ url: holder.dataPlaneUrl ?? prev.DesktopStream.url,
301
+ token: holder.streamToken ?? prev.DesktopStream.token,
302
+ expiresAt: holder.streamExpiresAt ?? prev.DesktopStream.expiresAt,
303
+ resolution: holder.resolution ?? prev.DesktopStream.resolution,
304
+ }
305
+ : prev.DesktopStream,
306
+ Terminal:
307
+ holder.terminalUrl && holder.terminalTransport
308
+ ? {
309
+ ...prev.Terminal,
310
+ transport: holder.terminalTransport,
311
+ url: holder.terminalUrl,
312
+ token: holder.terminalToken ?? prev.Terminal.token,
313
+ // A live pty-ws means the box is pty-capable for real.
314
+ ptyCapable: true,
315
+ reason: null,
316
+ }
317
+ : prev.Terminal,
318
+ }
319
+ : prev,
320
+ );
321
+ } catch (cause) {
322
+ if (cancelled) return;
323
+ if (cause instanceof OpenGeniApiError) {
324
+ if (cause.status === 409) {
325
+ // The un-redacted/shared consent gate is a DESKTOP requirement. A
326
+ // terminal-only warm attach needs no consent, so a 409 there is not
327
+ // a consent prompt — the terminal just stays on the read-only
328
+ // firehose. Only raise the consent requirement when the desktop was
329
+ // the (or a) reason we attached.
330
+ if (wantDesktopAttach) {
331
+ setAcknowledgmentRequired(
332
+ cause.message.includes("shared_acknowledgment") ? "shared" : "unredacted",
333
+ );
334
+ }
335
+ } else if (cause.status === 429) {
336
+ setViewerCapReached(true);
337
+ } else if (cause.status === 403) {
338
+ setState("error");
339
+ setError(cause);
340
+ return;
341
+ }
342
+ // 409/429 are recoverable: structured surfaces still negotiated;
343
+ // keep going to set ready/cold below.
344
+ } else {
345
+ throw cause;
346
+ }
347
+ }
348
+ }
349
+
350
+ if (caps.liveness === "warm" || caps.liveness === "draining" || localViewerId) {
351
+ setState("ready");
352
+ startHeartbeat(caps);
353
+ } else {
354
+ setState("cold");
355
+ pollUntilWarm();
356
+ }
357
+ } catch (cause) {
358
+ if (cancelled) return;
359
+ if (cause instanceof OpenGeniApiError && cause.status === 403) {
360
+ setState("error");
361
+ setError(new Error("not permitted to view this session's sandbox"));
362
+ return;
363
+ }
364
+ setState("error");
365
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
366
+ }
367
+ })();
368
+
369
+ return () => {
370
+ cancelled = true;
371
+ clearTimers();
372
+ // Fire-and-forget detach (idempotent delete-my-row). Capture the id so a
373
+ // re-render/unmount race still releases the right holder.
374
+ const releaseId = localViewerId ?? viewerIdRef.current;
375
+ if (releaseId) {
376
+ void client.detachViewer(workspaceId, sessionId, releaseId).catch(() => {});
377
+ }
378
+ };
379
+ // eslint-disable-next-line react-hooks/exhaustive-deps
380
+ }, [client, workspaceId, sessionId, enabled, attachDesktop, attachTerminal, attachFiles, warmingPollMs, warmingDeadlineMs, nonce]);
381
+
382
+ // ── Fold stream.url.rotated from the live event log (no round trip) ──────────
383
+ const events = options.events;
384
+ useEffect(() => {
385
+ if (!events || events.length === 0) return;
386
+ setCapabilities((prev) => {
387
+ if (!prev || !prev.DesktopStream.url) return prev;
388
+ let next = prev.DesktopStream;
389
+ let changed = false;
390
+ for (const rotation of rotationsFrom(events)) {
391
+ // Only this viewer's rotations (others' are filtered by viewerId when set).
392
+ if (rotation.viewerId && viewerIdRef.current && rotation.viewerId !== viewerIdRef.current) {
393
+ continue;
394
+ }
395
+ const applied = applyUrlRotation(next, rotation, epochRef.current);
396
+ if (applied) {
397
+ next = { ...next, url: applied.url, token: applied.token, expiresAt: applied.expiresAt };
398
+ epochRef.current = Math.max(epochRef.current, rotation.leaseEpoch);
399
+ changed = true;
400
+ }
401
+ }
402
+ return changed ? { ...prev, DesktopStream: next, leaseEpoch: epochRef.current } : prev;
403
+ });
404
+ }, [events]);
405
+
406
+ return {
407
+ capabilities,
408
+ state,
409
+ error,
410
+ acknowledgmentRequired,
411
+ viewerCapReached,
412
+ viewerId,
413
+ renegotiate,
414
+ };
415
+ }
@@ -0,0 +1,207 @@
1
+ import {
2
+ TTYD_SUBPROTOCOL,
3
+ TerminalCapability,
4
+ terminalSocketUrl,
5
+ ttydAuthFrame,
6
+ ttydInputFrame,
7
+ ttydResizeFrame,
8
+ TtydServerCommand,
9
+ } from "@opengeni/sdk";
10
+ import { useEffect, useMemo, useRef, useState } from "react";
11
+
12
+ /** The ttyd connection lifecycle as surfaced to the component. */
13
+ export type TerminalStreamStatus = "connecting" | "open" | "closed" | "error";
14
+
15
+ export type UseTerminalStreamOptions = {
16
+ /** The Terminal cell of the negotiated capabilities (`capabilities.Terminal`).
17
+ * The stream connects ONLY when `transport === "pty-ws"` and `url` is set; on a
18
+ * cold box (`transport === "sse-events"` / no url) it stays idle and the caller
19
+ * falls back to the Channel-A read-only firehose. */
20
+ capability: Pick<TerminalCapability, "transport" | "url" | "token"> | null;
21
+ /** Called for each OUTPUT payload from ttyd (write verbatim into xterm). */
22
+ onOutput?: ((data: string) => void) | undefined;
23
+ /** Called when ttyd sends a SET_WINDOW_TITLE frame. */
24
+ onTitle?: ((title: string) => void) | undefined;
25
+ /** Initial PTY size to seed the ttyd auth frame + first resize. */
26
+ initialCols?: number | undefined;
27
+ initialRows?: number | undefined;
28
+ };
29
+
30
+ export type UseTerminalStreamResult = {
31
+ /** True once the ttyd socket is open (and the auth frame has been sent). */
32
+ connected: boolean;
33
+ status: TerminalStreamStatus;
34
+ /** Pipe a keystroke/paste to the PTY stdin. No-op until the socket is open. */
35
+ write: (data: string) => void;
36
+ /** Tell ttyd the PTY window changed size (on xterm fit/resize). */
37
+ resize: (cols: number, rows: number) => void;
38
+ /** Tear the socket down (the effect also tears down on unmount / url change). */
39
+ disconnect: () => void;
40
+ };
41
+
42
+ /** Decode an inbound ttyd frame's payload (everything after the 1-char command).
43
+ * ttyd may send either a text frame (string) or a binary frame (ArrayBuffer);
44
+ * for binary we slice off the first byte (the command) and utf-8 decode the rest. */
45
+ function decodeFrame(data: string | ArrayBuffer): { command: string; payload: string } {
46
+ if (typeof data === "string") {
47
+ return { command: data.charAt(0), payload: data.slice(1) };
48
+ }
49
+ const bytes = new Uint8Array(data);
50
+ const command = bytes.length > 0 ? String.fromCharCode(bytes[0]!) : "";
51
+ const payload = bytes.length > 1 ? new TextDecoder().decode(bytes.subarray(1)) : "";
52
+ return { command, payload };
53
+ }
54
+
55
+ /**
56
+ * Drive a ttyd PTY-over-websocket connection from a `pty-ws` Terminal capability,
57
+ * symmetric with `use-desktop-stream` (the noVNC-over-tunnel hook). The scoped
58
+ * stream token is already embedded in the minted tunnel `url`; the WebSocket is
59
+ * opened with the REQUIRED ttyd subprotocol "tty".
60
+ *
61
+ * ttyd wire protocol (see `@opengeni/sdk/terminal`):
62
+ * - first frame: `JSON.stringify({ AuthToken: "" })` (+ optional columns/rows).
63
+ * - client→server: INPUT = "0"+data ; RESIZE = "1"+JSON({columns,rows}).
64
+ * - server→client: "0" = OUTPUT (→ xterm) ; "1" = SET_WINDOW_TITLE ;
65
+ * "2" = SET_PREFERENCES (ignored). Binary frames are decoded the same way.
66
+ *
67
+ * On a `url`/`token` rotation (a box rollover folds a fresh address into the cell)
68
+ * the effect re-runs: the old socket closes and a fresh one connects — a brief
69
+ * terminal blink, acceptable on rollover (mirrors the desktop's RFB hot-swap).
70
+ * SSR-safe: the socket open lives in `useEffect`, so a server render is a no-op.
71
+ */
72
+ export function useTerminalStream(options: UseTerminalStreamOptions): UseTerminalStreamResult {
73
+ const { capability, onOutput, onTitle, initialCols, initialRows } = options;
74
+ const [status, setStatus] = useState<TerminalStreamStatus>("closed");
75
+ const wsRef = useRef<WebSocket | null>(null);
76
+ // Latest size, so a resize() before the socket opens is replayed on open, and a
77
+ // reconnect seeds the right geometry.
78
+ const sizeRef = useRef<{ cols: number; rows: number }>({
79
+ cols: initialCols ?? 80,
80
+ rows: initialRows ?? 24,
81
+ });
82
+ // Keep the callbacks current without re-running the connect effect on every
83
+ // render (the parent passes fresh closures each time).
84
+ const onOutputRef = useRef(onOutput);
85
+ const onTitleRef = useRef(onTitle);
86
+ onOutputRef.current = onOutput;
87
+ onTitleRef.current = onTitle;
88
+
89
+ const transport = capability?.transport ?? null;
90
+ const url = capability?.url ?? null;
91
+ const token = capability?.token ?? null;
92
+
93
+ useEffect(() => {
94
+ // SSR / no WebSocket / not a live pty-ws cell: stay closed; the caller falls
95
+ // back to the Channel-A read-only firehose.
96
+ if (typeof window === "undefined" || typeof WebSocket === "undefined") return;
97
+ if (transport !== "pty-ws" || !url) {
98
+ setStatus("closed");
99
+ return;
100
+ }
101
+
102
+ let disposed = false;
103
+ let socket: WebSocket;
104
+ setStatus("connecting");
105
+ try {
106
+ // The ttyd "tty" subprotocol is REQUIRED — ttyd rejects a handshake without
107
+ // it. The scoped token is already in the tunnel `url`.
108
+ socket = new WebSocket(terminalSocketUrl({ url }), TTYD_SUBPROTOCOL);
109
+ } catch {
110
+ setStatus("error");
111
+ return;
112
+ }
113
+ socket.binaryType = "arraybuffer";
114
+ wsRef.current = socket;
115
+
116
+ socket.onopen = () => {
117
+ if (disposed) return;
118
+ // ttyd's required first frame: the auth message (empty token — the gate is
119
+ // the tunnel url + scoped stream token, not a ttyd -c credential), seeded
120
+ // with the current PTY geometry. Then an explicit resize to be safe.
121
+ try {
122
+ socket.send(ttydAuthFrame({ columns: sizeRef.current.cols, rows: sizeRef.current.rows }));
123
+ socket.send(ttydResizeFrame(sizeRef.current.cols, sizeRef.current.rows));
124
+ } catch {
125
+ // a closed socket between open and send — onclose handles state.
126
+ }
127
+ setStatus("open");
128
+ };
129
+
130
+ socket.onmessage = (ev: MessageEvent) => {
131
+ if (disposed) return;
132
+ const { command, payload } = decodeFrame(ev.data as string | ArrayBuffer);
133
+ switch (command) {
134
+ case TtydServerCommand.OUTPUT:
135
+ onOutputRef.current?.(payload);
136
+ break;
137
+ case TtydServerCommand.SET_WINDOW_TITLE:
138
+ onTitleRef.current?.(payload);
139
+ break;
140
+ // SET_PREFERENCES ("2") and anything else: ignored.
141
+ default:
142
+ break;
143
+ }
144
+ };
145
+
146
+ socket.onerror = () => {
147
+ if (!disposed) setStatus("error");
148
+ };
149
+ socket.onclose = () => {
150
+ if (!disposed) setStatus("closed");
151
+ };
152
+
153
+ return () => {
154
+ disposed = true;
155
+ wsRef.current = null;
156
+ // Drop handlers so an in-flight close/error doesn't mutate state post-unmount.
157
+ socket.onopen = null;
158
+ socket.onmessage = null;
159
+ socket.onerror = null;
160
+ socket.onclose = null;
161
+ try {
162
+ socket.close();
163
+ } catch {
164
+ // ignore teardown errors
165
+ }
166
+ };
167
+ // A url/token change (rotation) re-runs this effect → close old, open new.
168
+ // eslint-disable-next-line react-hooks/exhaustive-deps
169
+ }, [transport, url, token]);
170
+
171
+ return useMemo<UseTerminalStreamResult>(() => {
172
+ const write = (data: string) => {
173
+ const ws = wsRef.current;
174
+ if (ws && ws.readyState === WebSocket.OPEN) {
175
+ try {
176
+ ws.send(ttydInputFrame(data));
177
+ } catch {
178
+ // socket raced closed — the reconnect effect will re-establish.
179
+ }
180
+ }
181
+ };
182
+ const resize = (cols: number, rows: number) => {
183
+ if (cols <= 0 || rows <= 0) return;
184
+ sizeRef.current = { cols, rows };
185
+ const ws = wsRef.current;
186
+ if (ws && ws.readyState === WebSocket.OPEN) {
187
+ try {
188
+ ws.send(ttydResizeFrame(cols, rows));
189
+ } catch {
190
+ // socket raced closed — geometry is replayed on the next open.
191
+ }
192
+ }
193
+ };
194
+ const disconnect = () => {
195
+ const ws = wsRef.current;
196
+ wsRef.current = null;
197
+ if (ws) {
198
+ try {
199
+ ws.close();
200
+ } catch {
201
+ // ignore
202
+ }
203
+ }
204
+ };
205
+ return { connected: status === "open", status, write, resize, disconnect };
206
+ }, [status]);
207
+ }