@opengeni/react 0.3.1 → 0.5.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 +1055 -27
- package/dist/index.js +6993 -1954
- package/dist/index.js.map +1 -1
- package/package.json +65 -2
- package/src/client.ts +22 -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/fleet-tile.tsx +5 -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-session.ts +80 -12
- package/src/hooks/use-terminal-stream.ts +207 -0
- package/src/index.ts +112 -3
- package/src/lib/cn.ts +20 -1
- package/src/lib/git-patch.ts +43 -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 +253 -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,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
|
+
}
|
package/src/hooks/use-session.ts
CHANGED
|
@@ -1,33 +1,101 @@
|
|
|
1
|
-
import type { Session } from "@opengeni/sdk";
|
|
2
|
-
import { useCallback } from "react";
|
|
1
|
+
import type { Session, SessionEvent } from "@opengeni/sdk";
|
|
2
|
+
import { useCallback, useState } from "react";
|
|
3
3
|
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
4
|
-
import { usePolledValue } from "./internal";
|
|
4
|
+
import { useMutationRunner, usePolledValue, useSessionEventTrigger, type SessionEventFeedOptions } from "./internal";
|
|
5
5
|
|
|
6
|
-
export type UseSessionOptions = ClientOverride &
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
};
|
|
6
|
+
export type UseSessionOptions = ClientOverride &
|
|
7
|
+
SessionEventFeedOptions & {
|
|
8
|
+
/** Re-fetch on an interval (ms). Off by default — pair with `useSessionEvents` for live status. */
|
|
9
|
+
pollIntervalMs?: number | undefined;
|
|
10
|
+
};
|
|
11
11
|
|
|
12
12
|
export type UseSessionResult = {
|
|
13
13
|
session: Session | null;
|
|
14
14
|
loading: boolean;
|
|
15
15
|
error: Error | null;
|
|
16
16
|
refresh: () => Promise<void>;
|
|
17
|
+
/** Manually rename the session (PATCH, source='user'). Returns the updated session, or null on failure. */
|
|
18
|
+
updateTitle: (title: string) => Promise<Session | null>;
|
|
19
|
+
/** True while a rename is in flight. */
|
|
20
|
+
updating: boolean;
|
|
21
|
+
mutationError: Error | null;
|
|
22
|
+
clearMutationError: () => void;
|
|
17
23
|
};
|
|
18
24
|
|
|
19
|
-
/**
|
|
25
|
+
/** Event types that change the session title (auto + cross-client renames). */
|
|
26
|
+
export function isTitleEvent(event: Pick<SessionEvent, "type">): boolean {
|
|
27
|
+
return event.type === "session.title_set";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Fetch one session (with optional polling), live-patching its title on `session.title_set`. */
|
|
20
31
|
export function useSession(sessionId: string | null | undefined, options: UseSessionOptions = {}): UseSessionResult {
|
|
21
32
|
const { client, workspaceId } = useOpenGeni(options);
|
|
33
|
+
const enabled = (options.enabled ?? true) && Boolean(sessionId);
|
|
34
|
+
const [override, setOverride] = useState<Session | null>(null);
|
|
35
|
+
const mutation = useMutationRunner();
|
|
22
36
|
const load = useCallback(async () => {
|
|
23
37
|
if (!sessionId) {
|
|
24
38
|
return null;
|
|
25
39
|
}
|
|
26
|
-
|
|
40
|
+
const fetched = await client.getSession(workspaceId, sessionId);
|
|
41
|
+
// A fresh server read supersedes any optimistic/event-driven override.
|
|
42
|
+
setOverride(null);
|
|
43
|
+
return fetched;
|
|
27
44
|
}, [client, workspaceId, sessionId]);
|
|
28
45
|
const state = usePolledValue(load, {
|
|
29
46
|
pollIntervalMs: options.pollIntervalMs,
|
|
30
|
-
enabled
|
|
47
|
+
enabled,
|
|
31
48
|
});
|
|
32
|
-
|
|
49
|
+
|
|
50
|
+
const base = state.data ?? null;
|
|
51
|
+
// The override only ever carries title/titleSource patches; it is reset on
|
|
52
|
+
// every fresh load so it can never go stale against the server snapshot.
|
|
53
|
+
const session = base && override && override.id === base.id ? { ...base, title: override.title, titleSource: override.titleSource } : base;
|
|
54
|
+
|
|
55
|
+
// Live-patch the title on auto (agent) + cross-client (user/agent) renames so
|
|
56
|
+
// the UI reflects the new title without polling or a full re-fetch.
|
|
57
|
+
const onTitleEvent = useCallback((event: SessionEvent) => {
|
|
58
|
+
const payload = (event.payload ?? {}) as { title?: unknown; source?: unknown };
|
|
59
|
+
const title = payload.title;
|
|
60
|
+
if (typeof title !== "string") {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const source: "user" | "agent" | null = payload.source === "user" || payload.source === "agent" ? payload.source : null;
|
|
64
|
+
setOverride((current): Session | null => {
|
|
65
|
+
const next = current ?? base;
|
|
66
|
+
if (!next) {
|
|
67
|
+
return current;
|
|
68
|
+
}
|
|
69
|
+
return { ...next, title, titleSource: source };
|
|
70
|
+
});
|
|
71
|
+
}, [base]);
|
|
72
|
+
useSessionEventTrigger(client, workspaceId, sessionId, isTitleEvent, onTitleEvent, {
|
|
73
|
+
enabled,
|
|
74
|
+
...(options.events !== undefined ? { events: options.events } : {}),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const updateTitle = useCallback(
|
|
78
|
+
async (title: string): Promise<Session | null> => {
|
|
79
|
+
if (!sessionId) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const result = await mutation.run(() => client.updateSession(workspaceId, sessionId, { title }));
|
|
83
|
+
if (result) {
|
|
84
|
+
setOverride(result);
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
},
|
|
88
|
+
[client, workspaceId, sessionId, mutation.run],
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
session,
|
|
93
|
+
loading: state.loading,
|
|
94
|
+
error: state.error,
|
|
95
|
+
refresh: state.refresh,
|
|
96
|
+
updateTitle,
|
|
97
|
+
updating: mutation.mutating,
|
|
98
|
+
mutationError: mutation.mutationError,
|
|
99
|
+
clearMutationError: mutation.clearMutationError,
|
|
100
|
+
};
|
|
33
101
|
}
|