@opengeni/react 0.4.0 → 0.6.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/README.md +109 -2
- package/dist/chunk-DEW2ZNF2.js +1238 -0
- package/dist/chunk-DEW2ZNF2.js.map +1 -0
- package/dist/index.d.ts +191 -61
- package/dist/index.js +1501 -1009
- package/dist/index.js.map +1 -1
- package/dist/machines-BD6h9P_s.d.ts +329 -0
- package/dist/machines.d.ts +4 -0
- package/dist/machines.js +33 -0
- package/dist/machines.js.map +1 -0
- package/package.json +8 -2
- package/src/client.ts +1 -0
- package/src/components/desktop-viewer.tsx +11 -1
- package/src/components/enrollment-consent.tsx +245 -0
- package/src/components/enrollment-device-flow.tsx +182 -0
- package/src/components/fleet-tile.tsx +5 -0
- package/src/components/machine-card.tsx +131 -0
- package/src/components/machine-dock-bar.tsx +84 -0
- package/src/components/machine-metrics.tsx +184 -0
- package/src/components/machine-status-pill.tsx +157 -0
- package/src/components/machines-dashboard.tsx +151 -0
- package/src/components/message-timeline.tsx +106 -8
- package/src/components/workspace-dock.tsx +44 -10
- package/src/hooks/use-codex-accounts.ts +179 -0
- package/src/hooks/use-desktop-stream.ts +28 -2
- package/src/hooks/use-goal.ts +10 -2
- package/src/hooks/use-machines.ts +157 -0
- package/src/hooks/use-relay-frame-stream.ts +335 -0
- package/src/hooks/use-session.ts +80 -12
- package/src/index.ts +16 -1
- package/src/lib/git-patch.ts +10 -4
- package/src/lib/relay-wire.ts +116 -0
- package/src/machines.ts +50 -0
- package/src/timeline/activity-rail.tsx +13 -7
- package/src/timeline/index.ts +1 -0
- package/src/timeline/parsers.ts +6 -1
- package/src/timeline/projection.ts +203 -16
- package/src/timeline/turn-summary.tsx +32 -10
- package/src/timeline/types.ts +29 -3
- package/src/types/machines.ts +67 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodeStreamFrame,
|
|
3
|
+
decodeStreamOpenAck,
|
|
4
|
+
encodeStreamOpen,
|
|
5
|
+
STREAM_KIND_DESKTOP,
|
|
6
|
+
STREAM_ROLE_CLIENT,
|
|
7
|
+
} from "../lib/relay-wire";
|
|
8
|
+
import type { DesktopConnectionState, DesktopStreamCapability } from "@opengeni/sdk";
|
|
9
|
+
import { type RefObject, useEffect, useRef, useState } from "react";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The minimal WebSocket surface the frame renderer drives. Lets tests (and a
|
|
13
|
+
* future transport swap) inject a fake without a live socket — the exact
|
|
14
|
+
* analogue of `DesktopRfbFactory` for the noVNC path. Structurally a subset of
|
|
15
|
+
* the browser `WebSocket`.
|
|
16
|
+
*/
|
|
17
|
+
export interface DesktopWebSocketLike {
|
|
18
|
+
binaryType: string;
|
|
19
|
+
send(data: ArrayBuffer): void;
|
|
20
|
+
close(): void;
|
|
21
|
+
addEventListener(
|
|
22
|
+
type: "open" | "message" | "error" | "close",
|
|
23
|
+
listener: (ev: { data?: unknown } & Record<string, unknown>) => void,
|
|
24
|
+
): void;
|
|
25
|
+
removeEventListener(
|
|
26
|
+
type: "open" | "message" | "error" | "close",
|
|
27
|
+
listener: (ev: { data?: unknown } & Record<string, unknown>) => void,
|
|
28
|
+
): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type DesktopWebSocketFactory = (url: string) => DesktopWebSocketLike;
|
|
32
|
+
|
|
33
|
+
export type UseRelayFrameStreamOptions = {
|
|
34
|
+
/** The desktop cell of the negotiated capabilities (`capabilities.DesktopStream`). */
|
|
35
|
+
capability: DesktopStreamCapability | null;
|
|
36
|
+
/** The mount target. A `<canvas>` is appended here on connect. */
|
|
37
|
+
containerRef: RefObject<HTMLDivElement | null>;
|
|
38
|
+
/** Custom socket factory (tests / a transport swap). Defaults to `new WebSocket(url)`. */
|
|
39
|
+
webSocketFactory?: DesktopWebSocketFactory | undefined;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type UseRelayFrameStreamResult = {
|
|
43
|
+
state: DesktopConnectionState;
|
|
44
|
+
error: Error | null;
|
|
45
|
+
/** Tear down + reopen the socket (e.g. after a drop, once a fresh url arrives). */
|
|
46
|
+
reconnect: () => void;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Relay datagram tags: byte[0] of every binary message. Mirrors the proven wire
|
|
50
|
+
// protocol in `apps/api/scripts/diagnose-mac-desktop-stream.ts`.
|
|
51
|
+
const TAG_OPEN = 1;
|
|
52
|
+
const TAG_OPENACK = 2;
|
|
53
|
+
const TAG_FRAME = 3;
|
|
54
|
+
|
|
55
|
+
/** Build a relay datagram: a fresh Uint8Array of `body.length + 1` with the tag
|
|
56
|
+
* at [0] and the protobuf body copied at offset 1 (so `.buffer` is exact). */
|
|
57
|
+
function datagram(tag: number, body: Uint8Array): Uint8Array {
|
|
58
|
+
const out = new Uint8Array(body.length + 1);
|
|
59
|
+
out[0] = tag;
|
|
60
|
+
out.set(body, 1);
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function defaultWebSocketFactory(url: string): DesktopWebSocketLike {
|
|
65
|
+
// The browser `WebSocket` is structurally a superset of `DesktopWebSocketLike`
|
|
66
|
+
// (its event maps are stricter); cast so the seam stays loosely typed for fakes.
|
|
67
|
+
return new WebSocket(url) as unknown as DesktopWebSocketLike;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* VIEW-ONLY PNG-frame renderer for a SELF-HOSTED desktop stream — the relay
|
|
72
|
+
* transport (`transport: "relay-frames"`, `client: "frames"`). The self-hosted
|
|
73
|
+
* agent produces one PNG per frame as a protobuf datagram over the relay; there
|
|
74
|
+
* is no RFB/noVNC here. This hook opens the relay DESKTOP channel as a CLIENT
|
|
75
|
+
* (mirroring the proven diagnostic wire protocol), decodes each PNG onto a
|
|
76
|
+
* `<canvas>` mounted into `containerRef`, and drives the connection state
|
|
77
|
+
* machine: idle → connecting (ws opening) → connected (first painted frame) →
|
|
78
|
+
* error (ws error/close/ack rejection).
|
|
79
|
+
*
|
|
80
|
+
* It is DORMANT for any other transport (or no url): it stays idle and touches
|
|
81
|
+
* nothing, so the noVNC path owns the surface. SSR-safe: the socket + DOM attach
|
|
82
|
+
* live inside `useEffect`.
|
|
83
|
+
*
|
|
84
|
+
* Backpressure (critical — frames are ~1.8MB at ~10fps): at most one frame is
|
|
85
|
+
* decoded at a time; a frame arriving mid-decode replaces the pending one
|
|
86
|
+
* (latest-wins), so a slow decode can never build an unbounded queue.
|
|
87
|
+
*/
|
|
88
|
+
export function useRelayFrameStream(options: UseRelayFrameStreamOptions): UseRelayFrameStreamResult {
|
|
89
|
+
const { capability, containerRef, webSocketFactory } = options;
|
|
90
|
+
const [state, setState] = useState<DesktopConnectionState>("idle");
|
|
91
|
+
const [error, setError] = useState<Error | null>(null);
|
|
92
|
+
const [nonce, setNonce] = useState(0);
|
|
93
|
+
const stateRef = useRef<DesktopConnectionState>("idle");
|
|
94
|
+
const setBoth = (next: DesktopConnectionState) => {
|
|
95
|
+
stateRef.current = next;
|
|
96
|
+
setState(next);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const reconnect = () => setNonce((n) => n + 1);
|
|
100
|
+
|
|
101
|
+
const transport = capability?.transport ?? null;
|
|
102
|
+
const url = capability?.url ?? null;
|
|
103
|
+
const token = capability?.token ?? null;
|
|
104
|
+
|
|
105
|
+
// The factory is read via a ref so swapping it never re-opens the socket (it is
|
|
106
|
+
// not a connect-effect dependency), symmetric with the noVNC `rfbFactory`.
|
|
107
|
+
const factoryRef = useRef(webSocketFactory);
|
|
108
|
+
factoryRef.current = webSocketFactory;
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
// SSR / no DOM: stay idle and show the placeholder.
|
|
112
|
+
if (typeof window === "undefined") return;
|
|
113
|
+
// This hook OWNS the surface ONLY for the frame transport; anything else (or
|
|
114
|
+
// no live url) → dormant idle so the noVNC path can drive.
|
|
115
|
+
if (transport !== "relay-frames" || !url) {
|
|
116
|
+
setBoth("idle");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const container = containerRef.current;
|
|
120
|
+
if (!container) {
|
|
121
|
+
// Mount target not in the DOM yet (tab hidden) — stay idle so a re-attach
|
|
122
|
+
// nudge can re-run this effect once the container exists.
|
|
123
|
+
setBoth("idle");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Parse the 4 relay query params the channel is keyed on.
|
|
128
|
+
let channel: {
|
|
129
|
+
channelId: string;
|
|
130
|
+
workspaceId: string;
|
|
131
|
+
agentId: string;
|
|
132
|
+
kind: number;
|
|
133
|
+
port: number;
|
|
134
|
+
};
|
|
135
|
+
try {
|
|
136
|
+
const u = new URL(url);
|
|
137
|
+
channel = {
|
|
138
|
+
channelId: u.searchParams.get("channel") ?? "",
|
|
139
|
+
workspaceId: u.searchParams.get("ws") ?? "",
|
|
140
|
+
agentId: u.searchParams.get("agent") ?? "",
|
|
141
|
+
kind: STREAM_KIND_DESKTOP,
|
|
142
|
+
port: Number(u.searchParams.get("port") ?? "0"),
|
|
143
|
+
};
|
|
144
|
+
} catch (cause) {
|
|
145
|
+
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
146
|
+
setBoth("error");
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
setError(null);
|
|
151
|
+
setBoth("connecting");
|
|
152
|
+
|
|
153
|
+
// Canvas mount — mirror the noVNC surface conventions: absolute-fill the
|
|
154
|
+
// panel and CONTAIN the framebuffer (aspect-preserved, centered, letterboxed)
|
|
155
|
+
// — never distorted, never overflowing. `max-*: 100%` + `margin: auto` +
|
|
156
|
+
// intrinsic size gives fit-to-panel without a resize observer.
|
|
157
|
+
const canvas = document.createElement("canvas");
|
|
158
|
+
canvas.setAttribute("data-opengeni-desktop-frames", "");
|
|
159
|
+
const style = canvas.style;
|
|
160
|
+
style.position = "absolute";
|
|
161
|
+
style.top = "0";
|
|
162
|
+
style.left = "0";
|
|
163
|
+
style.right = "0";
|
|
164
|
+
style.bottom = "0";
|
|
165
|
+
style.margin = "auto";
|
|
166
|
+
style.maxWidth = "100%";
|
|
167
|
+
style.maxHeight = "100%";
|
|
168
|
+
style.width = "auto";
|
|
169
|
+
style.height = "auto";
|
|
170
|
+
style.display = "block";
|
|
171
|
+
style.imageRendering = "auto";
|
|
172
|
+
container.appendChild(canvas);
|
|
173
|
+
const ctx = canvas.getContext("2d");
|
|
174
|
+
|
|
175
|
+
let disposed = false;
|
|
176
|
+
let acked = false;
|
|
177
|
+
// Backpressure: decode at most ONE frame at a time; a frame arriving mid-
|
|
178
|
+
// decode replaces `pending` (keep only the LATEST) — never a queue.
|
|
179
|
+
let decoding = false;
|
|
180
|
+
let pending: Uint8Array | null = null;
|
|
181
|
+
|
|
182
|
+
const drainLatest = async () => {
|
|
183
|
+
if (decoding) return;
|
|
184
|
+
decoding = true;
|
|
185
|
+
try {
|
|
186
|
+
while (!disposed && pending) {
|
|
187
|
+
const data = pending;
|
|
188
|
+
pending = null;
|
|
189
|
+
let bmp: ImageBitmap;
|
|
190
|
+
try {
|
|
191
|
+
// Copy the exact frame bytes into a fresh ArrayBuffer-backed view:
|
|
192
|
+
// ts-proto's `bytes()` hands back a subarray VIEW into the larger
|
|
193
|
+
// message buffer, so slicing isolates just this PNG (and satisfies the
|
|
194
|
+
// `BlobPart` typing, which rejects the generic `ArrayBufferLike`).
|
|
195
|
+
bmp = await createImageBitmap(new Blob([new Uint8Array(data)], { type: "image/png" }));
|
|
196
|
+
} catch {
|
|
197
|
+
// A corrupt/partial frame is skipped; latest-wins keeps us live.
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (disposed) {
|
|
201
|
+
bmp.close();
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
// Size the backing store to the frame's natural WxH on the first frame
|
|
205
|
+
// (or when the resolution changes); CSS then scales the element to fit.
|
|
206
|
+
if (canvas.width !== bmp.width || canvas.height !== bmp.height) {
|
|
207
|
+
canvas.width = bmp.width;
|
|
208
|
+
canvas.height = bmp.height;
|
|
209
|
+
}
|
|
210
|
+
ctx?.drawImage(bmp, 0, 0, canvas.width, canvas.height);
|
|
211
|
+
bmp.close();
|
|
212
|
+
// First painted frame → connected.
|
|
213
|
+
if (!disposed && stateRef.current !== "connected") setBoth("connected");
|
|
214
|
+
}
|
|
215
|
+
} finally {
|
|
216
|
+
decoding = false;
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
let ws: DesktopWebSocketLike;
|
|
221
|
+
try {
|
|
222
|
+
ws = (factoryRef.current ?? defaultWebSocketFactory)(url);
|
|
223
|
+
} catch (cause) {
|
|
224
|
+
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
225
|
+
setBoth("error");
|
|
226
|
+
canvas.remove();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
ws.binaryType = "arraybuffer";
|
|
230
|
+
|
|
231
|
+
const onOpen = () => {
|
|
232
|
+
if (disposed) return;
|
|
233
|
+
const body = encodeStreamOpen({
|
|
234
|
+
channel,
|
|
235
|
+
token: token ?? "",
|
|
236
|
+
role: STREAM_ROLE_CLIENT,
|
|
237
|
+
resumeFromSeq: "0",
|
|
238
|
+
});
|
|
239
|
+
try {
|
|
240
|
+
ws.send(datagram(TAG_OPEN, body).buffer as ArrayBuffer);
|
|
241
|
+
} catch {
|
|
242
|
+
// Socket already closing; the close/error handler surfaces it.
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const onMessage = (ev: { data?: unknown }) => {
|
|
247
|
+
if (disposed) return;
|
|
248
|
+
const data = ev.data;
|
|
249
|
+
if (!(data instanceof ArrayBuffer)) return;
|
|
250
|
+
const buf = new Uint8Array(data);
|
|
251
|
+
if (buf.length === 0) return;
|
|
252
|
+
const tag = buf[0];
|
|
253
|
+
const rest = buf.subarray(1);
|
|
254
|
+
if (tag === TAG_OPENACK) {
|
|
255
|
+
let ack: ReturnType<typeof decodeStreamOpenAck>;
|
|
256
|
+
try {
|
|
257
|
+
ack = decodeStreamOpenAck(rest);
|
|
258
|
+
} catch {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (!ack.accepted) {
|
|
262
|
+
setError(
|
|
263
|
+
new Error(
|
|
264
|
+
ack.error?.message
|
|
265
|
+
? `desktop stream rejected: ${ack.error.message}`
|
|
266
|
+
: "desktop stream rejected by the relay",
|
|
267
|
+
),
|
|
268
|
+
);
|
|
269
|
+
setBoth("error");
|
|
270
|
+
try {
|
|
271
|
+
ws.close();
|
|
272
|
+
} catch {
|
|
273
|
+
// already closed
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
// Accepted — stay "connecting" until the first frame paints.
|
|
278
|
+
acked = true;
|
|
279
|
+
} else if (tag === TAG_FRAME) {
|
|
280
|
+
let fr: ReturnType<typeof decodeStreamFrame>;
|
|
281
|
+
try {
|
|
282
|
+
fr = decodeStreamFrame(rest);
|
|
283
|
+
} catch {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (fr.data && fr.data.length > 0) {
|
|
287
|
+
pending = fr.data; // latest-wins backpressure
|
|
288
|
+
void drainLatest();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const onError = () => {
|
|
294
|
+
if (disposed) return;
|
|
295
|
+
setError((prev) => prev ?? new Error("desktop stream connection error"));
|
|
296
|
+
setBoth("error");
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const onClose = () => {
|
|
300
|
+
if (disposed) return;
|
|
301
|
+
setError(
|
|
302
|
+
(prev) =>
|
|
303
|
+
prev ??
|
|
304
|
+
new Error(acked ? "desktop stream closed" : "desktop stream closed before it opened"),
|
|
305
|
+
);
|
|
306
|
+
setBoth("error");
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
ws.addEventListener("open", onOpen);
|
|
310
|
+
ws.addEventListener("message", onMessage);
|
|
311
|
+
ws.addEventListener("error", onError);
|
|
312
|
+
ws.addEventListener("close", onClose);
|
|
313
|
+
|
|
314
|
+
return () => {
|
|
315
|
+
disposed = true;
|
|
316
|
+
pending = null;
|
|
317
|
+
ws.removeEventListener("open", onOpen);
|
|
318
|
+
ws.removeEventListener("message", onMessage);
|
|
319
|
+
ws.removeEventListener("error", onError);
|
|
320
|
+
ws.removeEventListener("close", onClose);
|
|
321
|
+
try {
|
|
322
|
+
ws.close();
|
|
323
|
+
} catch {
|
|
324
|
+
// ignore teardown errors
|
|
325
|
+
}
|
|
326
|
+
canvas.remove();
|
|
327
|
+
};
|
|
328
|
+
// ONLY a real transport change reopens: a fresh url (rotation), a new token,
|
|
329
|
+
// the transport flipping, or a manual reconnect (`nonce`). The factory is read
|
|
330
|
+
// via a ref and must never re-open the socket.
|
|
331
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
332
|
+
}, [url, token, transport, nonce]);
|
|
333
|
+
|
|
334
|
+
return { state, error, reconnect };
|
|
335
|
+
}
|
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
|
}
|
package/src/index.ts
CHANGED
|
@@ -9,7 +9,7 @@ export { OpenGeniProvider, useOpenGeni, useOpenGeniClient } from "./provider";
|
|
|
9
9
|
export type { ClientOverride, OpenGeniContextValue, OpenGeniProviderProps } from "./provider";
|
|
10
10
|
|
|
11
11
|
// Hooks
|
|
12
|
-
export { useSession } from "./hooks/use-session";
|
|
12
|
+
export { useSession, isTitleEvent } from "./hooks/use-session";
|
|
13
13
|
export type { UseSessionOptions, UseSessionResult } from "./hooks/use-session";
|
|
14
14
|
export { useSessionEvents } from "./hooks/use-session-events";
|
|
15
15
|
export type { SessionEventsConnectionState, UseSessionEventsOptions, UseSessionEventsResult } from "./hooks/use-session-events";
|
|
@@ -55,6 +55,13 @@ export type {
|
|
|
55
55
|
} from "./hooks/use-session-capabilities";
|
|
56
56
|
export { useDesktopStream } from "./hooks/use-desktop-stream";
|
|
57
57
|
export type { UseDesktopStreamOptions, UseDesktopStreamResult } from "./hooks/use-desktop-stream";
|
|
58
|
+
export { useRelayFrameStream } from "./hooks/use-relay-frame-stream";
|
|
59
|
+
export type {
|
|
60
|
+
DesktopWebSocketFactory,
|
|
61
|
+
DesktopWebSocketLike,
|
|
62
|
+
UseRelayFrameStreamOptions,
|
|
63
|
+
UseRelayFrameStreamResult,
|
|
64
|
+
} from "./hooks/use-relay-frame-stream";
|
|
58
65
|
export { useTerminalStream } from "./hooks/use-terminal-stream";
|
|
59
66
|
export type {
|
|
60
67
|
TerminalStreamStatus,
|
|
@@ -99,6 +106,7 @@ export type {
|
|
|
99
106
|
SessionStatusItem,
|
|
100
107
|
TimelineGroup,
|
|
101
108
|
TimelineItem,
|
|
109
|
+
TurnEndItem,
|
|
102
110
|
ToolCallItem,
|
|
103
111
|
UserMessageItem,
|
|
104
112
|
WorkerItem,
|
|
@@ -221,6 +229,13 @@ export type { DesktopViewerProps } from "./components/desktop-viewer";
|
|
|
221
229
|
export { WorkspaceDock } from "./components/workspace-dock";
|
|
222
230
|
export type { WorkspaceDockProps, WorkspaceTab } from "./components/workspace-dock";
|
|
223
231
|
|
|
232
|
+
// Connected-machine UI moved to the "@opengeni/react/machines" subpath; re-exported
|
|
233
|
+
// here for back-compat (#144).
|
|
234
|
+
export * from "./machines";
|
|
235
|
+
// Multi-account Codex (P1): accounts list + active-switch hook.
|
|
236
|
+
export { useCodexAccounts, isCodexAccountEvent } from "./hooks/use-codex-accounts";
|
|
237
|
+
export type { CodexAccountsClientLike, UseCodexAccountsOptions, UseCodexAccountsResult } from "./hooks/use-codex-accounts";
|
|
238
|
+
|
|
224
239
|
// Sandbox helpers
|
|
225
240
|
export { gitFileDiffToPatch } from "./lib/git-patch";
|
|
226
241
|
export { xtermThemeFromTokens } from "./lib/xterm-theme";
|
package/src/lib/git-patch.ts
CHANGED
|
@@ -22,10 +22,16 @@ export function gitFileDiffToPatch(file: GitFileDiff): string {
|
|
|
22
22
|
lines.push(`+++ b/${newPath}`);
|
|
23
23
|
}
|
|
24
24
|
for (const hunk of file.hunks) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
// Only trust a pre-parsed header if it carries the full unified range form
|
|
26
|
+
// `@@ -<o>[,<n>] +<o>[,<n>] @@`. A synthesized create_file hunk (parsers.ts)
|
|
27
|
+
// can carry a degenerate `@@ +1 @@` with no `-`/`+` ranges; a generic patch
|
|
28
|
+
// parser (Pierre) renders zero lines from it, so the expanded diff comes up
|
|
29
|
+
// empty while the collapsed chip still shows the (correct) addition count.
|
|
30
|
+
// In that case regenerate a valid header from the hunk's range fields.
|
|
31
|
+
const headerIsValid = /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/.test(hunk.header ?? "");
|
|
32
|
+
const header = headerIsValid
|
|
33
|
+
? hunk.header
|
|
34
|
+
: `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
|
|
29
35
|
lines.push(header);
|
|
30
36
|
for (const line of hunk.lines) {
|
|
31
37
|
if (line.type === "meta") continue;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Minimal HAND-MIRROR of the relay stream wire messages the desktop frame client
|
|
2
|
+
// needs (`StreamOpen` encode; `StreamOpenAck` + `StreamFrame` decode). It exists
|
|
3
|
+
// because the publish-closure guard (scripts/publish-closure-guard.ts) forbids
|
|
4
|
+
// `@opengeni/react` from depending on `@opengeni/agent-proto` — the published SDK
|
|
5
|
+
// closure may only reach `@opengeni/sdk` among `@opengeni/*` packages. So, exactly
|
|
6
|
+
// as the guard prescribes ("hand-mirror it"), we re-encode just these three
|
|
7
|
+
// messages against the third-party `@bufbuild/protobuf/wire` runtime (the same
|
|
8
|
+
// runtime `@opengeni/agent-proto` generates against), byte-compatible with the
|
|
9
|
+
// generated code. Field numbers copied verbatim from
|
|
10
|
+
// `packages/agent-proto/src/gen/opengeni_agent.ts`:
|
|
11
|
+
// StreamChannel: channelId=1, workspaceId=2, agentId=3, kind=4(int32), port=5(uint32)
|
|
12
|
+
// StreamOpen: channel=1(msg), token=2, role=3(int32), resumeFromSeq=4(uint64, omitted when "0")
|
|
13
|
+
// StreamOpenAck: accepted=1(bool), error=2(AgentError msg), resumeFromSeq=3
|
|
14
|
+
// AgentError: code=1, message=2, retryable=3, detail=4 (we read only message)
|
|
15
|
+
// StreamFrame: channelId=1, seq=2, data=3(bytes), producedAtMs=4 (we read only data)
|
|
16
|
+
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
|
|
17
|
+
|
|
18
|
+
/** `StreamKind.STREAM_KIND_DESKTOP` (agent-proto enum value). */
|
|
19
|
+
export const STREAM_KIND_DESKTOP = 2;
|
|
20
|
+
/** `StreamRole.STREAM_ROLE_CLIENT` (agent-proto enum value). */
|
|
21
|
+
export const STREAM_ROLE_CLIENT = 2;
|
|
22
|
+
|
|
23
|
+
export interface RelayChannel {
|
|
24
|
+
channelId: string;
|
|
25
|
+
workspaceId: string;
|
|
26
|
+
agentId: string;
|
|
27
|
+
kind: number;
|
|
28
|
+
port: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RelayStreamOpen {
|
|
32
|
+
channel: RelayChannel;
|
|
33
|
+
token: string;
|
|
34
|
+
role: number;
|
|
35
|
+
resumeFromSeq: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function encodeChannel(message: RelayChannel, writer: BinaryWriter): BinaryWriter {
|
|
39
|
+
if (message.channelId !== "") writer.uint32(10).string(message.channelId);
|
|
40
|
+
if (message.workspaceId !== "") writer.uint32(18).string(message.workspaceId);
|
|
41
|
+
if (message.agentId !== "") writer.uint32(26).string(message.agentId);
|
|
42
|
+
if (message.kind !== 0) writer.uint32(32).int32(message.kind);
|
|
43
|
+
if (message.port !== 0) writer.uint32(40).uint32(message.port);
|
|
44
|
+
return writer;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Encode a `StreamOpen`, byte-compatible with `StreamOpen.encode(...).finish()`. */
|
|
48
|
+
export function encodeStreamOpen(message: RelayStreamOpen): Uint8Array {
|
|
49
|
+
const writer = new BinaryWriter();
|
|
50
|
+
encodeChannel(message.channel, writer.uint32(10).fork()).join();
|
|
51
|
+
if (message.token !== "") writer.uint32(18).string(message.token);
|
|
52
|
+
if (message.role !== 0) writer.uint32(24).int32(message.role);
|
|
53
|
+
if (message.resumeFromSeq !== "0") writer.uint32(32).uint64(message.resumeFromSeq);
|
|
54
|
+
return writer.finish();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The only `AgentError` field we surface from a rejected ack. */
|
|
58
|
+
function decodeAgentErrorMessage(reader: BinaryReader, length: number): { message?: string } {
|
|
59
|
+
const end = reader.pos + length;
|
|
60
|
+
let message: string | undefined;
|
|
61
|
+
while (reader.pos < end) {
|
|
62
|
+
const tag = reader.uint32();
|
|
63
|
+
if (tag >>> 3 === 2 && tag === 18) {
|
|
64
|
+
message = reader.string();
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if ((tag & 7) === 4 || tag === 0) break;
|
|
68
|
+
reader.skip(tag & 7);
|
|
69
|
+
}
|
|
70
|
+
return message !== undefined ? { message } : {};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Decode a `StreamOpenAck` (we need `accepted` + the error message). */
|
|
74
|
+
export function decodeStreamOpenAck(bytes: Uint8Array): {
|
|
75
|
+
accepted: boolean;
|
|
76
|
+
error?: { message?: string };
|
|
77
|
+
} {
|
|
78
|
+
const reader = new BinaryReader(bytes);
|
|
79
|
+
const end = reader.len;
|
|
80
|
+
let accepted = false;
|
|
81
|
+
let error: { message?: string } | undefined;
|
|
82
|
+
while (reader.pos < end) {
|
|
83
|
+
const tag = reader.uint32();
|
|
84
|
+
if (tag >>> 3 === 1 && tag === 8) {
|
|
85
|
+
accepted = reader.bool();
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (tag >>> 3 === 2 && tag === 18) {
|
|
89
|
+
error = decodeAgentErrorMessage(reader, reader.uint32());
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if ((tag & 7) === 4 || tag === 0) break;
|
|
93
|
+
reader.skip(tag & 7);
|
|
94
|
+
}
|
|
95
|
+
return error !== undefined ? { accepted, error } : { accepted };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Decode a `StreamFrame`, extracting only the `data` (framebuffer PNG) bytes. */
|
|
99
|
+
export function decodeStreamFrame(bytes: Uint8Array): { data: Uint8Array } {
|
|
100
|
+
const reader = new BinaryReader(bytes);
|
|
101
|
+
const end = reader.len;
|
|
102
|
+
let data = new Uint8Array(0);
|
|
103
|
+
while (reader.pos < end) {
|
|
104
|
+
const tag = reader.uint32();
|
|
105
|
+
if (tag >>> 3 === 3 && tag === 26) {
|
|
106
|
+
// Copy into a fresh ArrayBuffer-backed view: BinaryReader.bytes() returns
|
|
107
|
+
// `Uint8Array<ArrayBufferLike>` (a view into the message buffer), which both
|
|
108
|
+
// narrows the type to `Uint8Array<ArrayBuffer>` and isolates just this frame.
|
|
109
|
+
data = new Uint8Array(reader.bytes());
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if ((tag & 7) === 4 || tag === 0) break;
|
|
113
|
+
reader.skip(tag & 7);
|
|
114
|
+
}
|
|
115
|
+
return { data };
|
|
116
|
+
}
|
package/src/machines.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// @opengeni/react/machines — Bring-your-own-compute: Machines dashboard +
|
|
2
|
+
// enrollment flow + status surfacing (M9). View-model types mirror the M10
|
|
3
|
+
// contract shape (MachineView / MetricSample / MachinesResponse) — see
|
|
4
|
+
// types/machines.ts.
|
|
5
|
+
//
|
|
6
|
+
// This is a self-contained island carved out of the package root so consumers
|
|
7
|
+
// that don't surface connected machines never pull it in. The root still
|
|
8
|
+
// re-exports everything here for backwards compatibility (deprecated).
|
|
9
|
+
export type {
|
|
10
|
+
ConnectionStatus,
|
|
11
|
+
MachineKind,
|
|
12
|
+
MachineMetricsSeriesResponse,
|
|
13
|
+
MachineState,
|
|
14
|
+
MachineView,
|
|
15
|
+
MachinesResponse,
|
|
16
|
+
MetricSample,
|
|
17
|
+
} from "./types/machines";
|
|
18
|
+
export { connectionStatusForState } from "./types/machines";
|
|
19
|
+
export {
|
|
20
|
+
ConnectionDot,
|
|
21
|
+
ConnectionStatusPill,
|
|
22
|
+
MachineStatusPill,
|
|
23
|
+
CONNECTION_STATUS_META,
|
|
24
|
+
MACHINE_STATE_BADGE_META,
|
|
25
|
+
} from "./components/machine-status-pill";
|
|
26
|
+
export type {
|
|
27
|
+
ConnectionDotProps,
|
|
28
|
+
ConnectionStatusMeta,
|
|
29
|
+
ConnectionStatusPillProps,
|
|
30
|
+
MachineStateBadgeMeta,
|
|
31
|
+
MachineStatusPillProps,
|
|
32
|
+
} from "./components/machine-status-pill";
|
|
33
|
+
export { MachineMetrics } from "./components/machine-metrics";
|
|
34
|
+
export type { MachineMetricsProps } from "./components/machine-metrics";
|
|
35
|
+
export { MachineCard } from "./components/machine-card";
|
|
36
|
+
export type { MachineCardProps } from "./components/machine-card";
|
|
37
|
+
export { MachinesDashboard } from "./components/machines-dashboard";
|
|
38
|
+
export type { MachinesDashboardProps } from "./components/machines-dashboard";
|
|
39
|
+
export { MachineDockBar, SharedMachineDisclosure } from "./components/machine-dock-bar";
|
|
40
|
+
export type { MachineDockBarProps, SharedMachineDisclosureProps } from "./components/machine-dock-bar";
|
|
41
|
+
export { EnrollmentDeviceFlow } from "./components/enrollment-device-flow";
|
|
42
|
+
export type { DeviceFlowPhase, EnrollmentDeviceFlowProps } from "./components/enrollment-device-flow";
|
|
43
|
+
export { EnrollmentConsent } from "./components/enrollment-consent";
|
|
44
|
+
export type {
|
|
45
|
+
EnrollmentConsentMachine,
|
|
46
|
+
EnrollmentConsentPhase,
|
|
47
|
+
EnrollmentConsentProps,
|
|
48
|
+
} from "./components/enrollment-consent";
|
|
49
|
+
export { useMachines } from "./hooks/use-machines";
|
|
50
|
+
export type { MachinesClientLike, UseMachinesOptions, UseMachinesResult } from "./hooks/use-machines";
|