@opengeni/react 0.13.0 → 0.15.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 (41) hide show
  1. package/README.md +19 -13
  2. package/dist/chunk-TOJR776I.js +2280 -0
  3. package/dist/chunk-TOJR776I.js.map +1 -0
  4. package/dist/index.d.ts +314 -255
  5. package/dist/index.js +5862 -4404
  6. package/dist/index.js.map +1 -1
  7. package/dist/{machines-CnlMb7E-.d.ts → machines-BpdwuQcD.d.ts} +130 -10
  8. package/dist/machines.d.ts +1 -1
  9. package/dist/machines.js +23 -1
  10. package/package.json +5 -2
  11. package/src/client.ts +10 -1
  12. package/src/components/chat-composer.tsx +309 -57
  13. package/src/components/machine-card.tsx +81 -15
  14. package/src/components/machine-health-pill.tsx +68 -0
  15. package/src/components/machine-metrics.tsx +10 -24
  16. package/src/components/machines/health.ts +146 -0
  17. package/src/components/machines/machine-detail.tsx +220 -0
  18. package/src/components/machines/metric-history-chart.tsx +298 -0
  19. package/src/components/machines/metric-sparkline.tsx +76 -0
  20. package/src/components/machines/series.ts +113 -0
  21. package/src/components/machines-dashboard.tsx +13 -1
  22. package/src/components/queue-surface.tsx +578 -0
  23. package/src/components/sandbox-files.tsx +94 -9
  24. package/src/components/sandbox-workspace.tsx +186 -52
  25. package/src/components/session-status.tsx +0 -6
  26. package/src/components/workbench-changes.tsx +64 -20
  27. package/src/components/workspace-dock.tsx +146 -55
  28. package/src/hooks/use-composer.ts +369 -39
  29. package/src/hooks/use-session-control.ts +6 -7
  30. package/src/hooks/use-session-events.ts +3 -2
  31. package/src/hooks/use-session-lineage.ts +15 -6
  32. package/src/hooks/use-session.ts +10 -2
  33. package/src/hooks/use-turn-queue.ts +175 -47
  34. package/src/index.ts +13 -7
  35. package/src/machines.ts +16 -0
  36. package/src/provider.tsx +192 -5
  37. package/src/timeline/parsers.ts +43 -6
  38. package/src/timeline/projection.ts +24 -2
  39. package/styles/index.css +22 -0
  40. package/dist/chunk-NFYVQWIB.js +0 -1377
  41. package/dist/chunk-NFYVQWIB.js.map +0 -1
@@ -1,5 +1,5 @@
1
1
  import type { Session, SessionEvent } from "@opengeni/sdk";
2
- import { useCallback, useState } from "react";
2
+ import { useCallback, useEffect, useState } from "react";
3
3
  import { useOpenGeni, type ClientOverride } from "../provider";
4
4
  import {
5
5
  useMutationRunner,
@@ -37,7 +37,8 @@ export function useSession(
37
37
  sessionId: string | null | undefined,
38
38
  options: UseSessionOptions = {},
39
39
  ): UseSessionResult {
40
- const { client, workspaceId } = useOpenGeni(options);
40
+ const { client, workspaceId, workspaceControlEvent, registerSessionReconciler } =
41
+ useOpenGeni(options);
41
42
  const enabled = (options.enabled ?? true) && Boolean(sessionId);
42
43
  const [override, setOverride] = useState<Session | null>(null);
43
44
  const { run, mutating, mutationError, clearMutationError } = useMutationRunner();
@@ -54,6 +55,13 @@ export function useSession(
54
55
  pollIntervalMs: options.pollIntervalMs,
55
56
  enabled,
56
57
  });
58
+ useEffect(() => {
59
+ if (enabled && workspaceControlEvent) void refresh();
60
+ }, [enabled, refresh, workspaceControlEvent]);
61
+ useEffect(() => {
62
+ if (!sessionId || !enabled) return;
63
+ return registerSessionReconciler(sessionId, "session", refresh);
64
+ }, [enabled, refresh, registerSessionReconciler, sessionId]);
57
65
 
58
66
  const base = data ?? null;
59
67
  // The override only ever carries title/titleSource patches; it is reset on
@@ -1,14 +1,20 @@
1
- import type { SessionEvent, SessionQueueSnapshot, SessionTurn } from "@opengeni/sdk";
2
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import type {
2
+ ComposerDraft,
3
+ EffectiveSessionControl,
4
+ SessionEvent,
5
+ SessionQueueMutationResponse,
6
+ SessionQueueSnapshot,
7
+ SessionTurn,
8
+ } from "@opengeni/sdk";
9
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
10
  import { useOpenGeni, type ClientOverride } from "../provider";
4
11
  import {
5
12
  useDebouncedCallback,
6
- useMutationRunner,
7
13
  useSessionEventTrigger,
8
14
  type SessionEventFeedOptions,
9
15
  } from "./internal";
10
16
 
11
- /** Events that can change the authoritative prompt queue or its pause gates. */
17
+ /** Events that can change the authoritative prompt queue or effective control. */
12
18
  export function isTurnQueueEvent(event: Pick<SessionEvent, "type">): boolean {
13
19
  return (
14
20
  event.type.startsWith("turn.") ||
@@ -18,6 +24,8 @@ export function isTurnQueueEvent(event: Pick<SessionEvent, "type">): boolean {
18
24
  );
19
25
  }
20
26
 
27
+ export type QueueMutationKind = "move" | "edit" | "steer" | "delete";
28
+
21
29
  export type UseTurnQueueOptions = ClientOverride &
22
30
  SessionEventFeedOptions & {
23
31
  pollIntervalMs?: number | undefined;
@@ -27,68 +35,91 @@ export type UseTurnQueueResult = {
27
35
  snapshot: SessionQueueSnapshot | null;
28
36
  /** Human/API prompts exactly in server execution order. Never client-sorted. */
29
37
  queue: SessionTurn[];
30
- controlState: SessionQueueSnapshot["controlState"] | null;
31
- controlGeneration: number | null;
32
- workspaceInferenceState: SessionQueueSnapshot["workspaceInferenceState"] | null;
33
- workspaceInferenceGeneration: number | null;
34
- workspaceRunExceptionGeneration: number | null;
38
+ effectiveControl: EffectiveSessionControl | null;
35
39
  loading: boolean;
36
40
  error: Error | null;
37
41
  refresh: () => Promise<void>;
38
- /** Delete a waiting prompt before the worker claims it. */
42
+ moveTurn: (turnId: string, beforeTurnId: string | null) => Promise<boolean>;
43
+ /** Atomically withdraw a waiting prompt into the private durable composer draft. */
44
+ editTurn: (
45
+ turnId: string,
46
+ options: { expectedDraftRevision: number; replaceDraft: boolean },
47
+ ) => Promise<ComposerDraft | null>;
48
+ /** Advance the same durable waiting prompt; no duplicate prompt is created. */
49
+ steerTurn: (turnId: string) => Promise<boolean>;
39
50
  removeTurn: (turnId: string) => Promise<boolean>;
51
+ pendingByTurn: Readonly<Record<string, QueueMutationKind>>;
52
+ mutationFor: (turnId: string) => QueueMutationKind | null;
40
53
  mutating: boolean;
41
54
  mutationError: Error | null;
42
55
  clearMutationError: () => void;
43
56
  };
44
57
 
45
58
  /**
46
- * The single authoritative waiting-prompt queue. The server owns ordering; the
47
- * client renders the returned array verbatim and supports deletion only.
59
+ * The one authoritative human prompt queue. Every mutation carries the exact
60
+ * server versions the operator saw and accepts only monotonic snapshots. A
61
+ * conflict immediately reloads server truth; the client never invents order.
48
62
  */
49
63
  export function useTurnQueue(
50
64
  sessionId: string | null | undefined,
51
65
  options: UseTurnQueueOptions = {},
52
66
  ): UseTurnQueueResult {
53
- const { client, workspaceId } = useOpenGeni(options);
67
+ const { client, workspaceId, workspaceControlEvent, registerSessionReconciler } =
68
+ useOpenGeni(options);
54
69
  const enabled = (options.enabled ?? true) && Boolean(sessionId);
55
70
  const [snapshot, setSnapshot] = useState<SessionQueueSnapshot | null>(null);
56
71
  const [loading, setLoading] = useState(enabled);
57
72
  const [error, setError] = useState<Error | null>(null);
58
- const { run, mutating, mutationError, clearMutationError } = useMutationRunner();
59
- const generation = useRef(0);
73
+ const [mutationError, setMutationError] = useState<Error | null>(null);
74
+ const [pendingByTurn, setPendingByTurn] = useState<Record<string, QueueMutationKind>>({});
75
+ const pendingRef = useRef<Record<string, QueueMutationKind>>({});
76
+ const readGeneration = useRef(0);
60
77
  const targetKeyRef = useRef<string | null>(null);
61
78
  const snapshotRef = useRef<SessionQueueSnapshot | null>(null);
62
79
 
63
- const replaceSnapshot = useCallback((next: SessionQueueSnapshot | null): void => {
80
+ const acceptSnapshot = useCallback((next: SessionQueueSnapshot | null): boolean => {
81
+ const current = snapshotRef.current;
82
+ if (
83
+ next &&
84
+ current &&
85
+ (next.version < current.version ||
86
+ next.effectiveControl.controlVersion < current.effectiveControl.controlVersion)
87
+ ) {
88
+ return false;
89
+ }
64
90
  snapshotRef.current = next;
65
91
  setSnapshot(next);
92
+ return true;
66
93
  }, []);
67
94
 
68
95
  const load = useCallback(async (): Promise<void> => {
69
96
  if (!sessionId) return;
70
- const ticket = ++generation.current;
97
+ const ticket = ++readGeneration.current;
71
98
  try {
72
99
  const fetched = await client.getQueue(workspaceId, sessionId);
73
- if (ticket === generation.current) {
74
- replaceSnapshot(fetched);
100
+ if (ticket === readGeneration.current) {
101
+ acceptSnapshot(fetched);
75
102
  setError(null);
76
103
  setLoading(false);
77
104
  }
78
105
  } catch (cause) {
79
- if (ticket === generation.current) {
80
- setError(cause instanceof Error ? cause : new Error(String(cause)));
106
+ if (ticket === readGeneration.current) {
107
+ setError(asError(cause));
81
108
  setLoading(false);
82
109
  }
83
110
  }
84
- }, [client, workspaceId, sessionId, replaceSnapshot]);
111
+ }, [client, workspaceId, sessionId, acceptSnapshot]);
85
112
 
86
113
  useEffect(() => {
87
114
  const targetKey = `${workspaceId}\u0000${sessionId ?? ""}`;
88
115
  if (targetKeyRef.current !== targetKey) {
89
116
  targetKeyRef.current = targetKey;
90
- replaceSnapshot(null);
117
+ readGeneration.current += 1;
118
+ acceptSnapshot(null);
91
119
  setError(null);
120
+ setMutationError(null);
121
+ setPendingByTurn({});
122
+ pendingRef.current = {};
92
123
  }
93
124
  if (!enabled) {
94
125
  setLoading(false);
@@ -99,15 +130,23 @@ export function useTurnQueue(
99
130
  const pollIntervalMs = options.pollIntervalMs;
100
131
  if (pollIntervalMs === undefined || pollIntervalMs <= 0) {
101
132
  return () => {
102
- generation.current += 1;
133
+ readGeneration.current += 1;
103
134
  };
104
135
  }
105
136
  const timer = setInterval(() => void load(), pollIntervalMs);
106
137
  return () => {
107
138
  clearInterval(timer);
108
- generation.current += 1;
139
+ readGeneration.current += 1;
109
140
  };
110
- }, [load, enabled, workspaceId, sessionId, options.pollIntervalMs, replaceSnapshot]);
141
+ }, [load, enabled, workspaceId, sessionId, options.pollIntervalMs, acceptSnapshot]);
142
+
143
+ useEffect(() => {
144
+ if (enabled && workspaceControlEvent) void load();
145
+ }, [enabled, load, workspaceControlEvent]);
146
+ useEffect(() => {
147
+ if (!sessionId || !enabled) return;
148
+ return registerSessionReconciler(sessionId, "queue", load);
149
+ }, [enabled, load, registerSessionReconciler, sessionId]);
111
150
 
112
151
  const scheduleRefresh = useDebouncedCallback(() => void load());
113
152
  useSessionEventTrigger(client, workspaceId, sessionId, isTurnQueueEvent, scheduleRefresh, {
@@ -115,42 +154,131 @@ export function useTurnQueue(
115
154
  ...(options.events !== undefined ? { events: options.events } : {}),
116
155
  });
117
156
 
118
- const removeTurn = useCallback(
119
- async (turnId: string): Promise<boolean> => {
120
- if (!sessionId) return false;
157
+ const mutate = useCallback(
158
+ async (
159
+ turnId: string,
160
+ kind: QueueMutationKind,
161
+ command: (
162
+ current: SessionQueueSnapshot,
163
+ turn: SessionTurn,
164
+ ) => Promise<SessionQueueMutationResponse>,
165
+ ): Promise<SessionQueueMutationResponse | null> => {
166
+ if (!sessionId || pendingRef.current[turnId]) return null;
121
167
  const current = snapshotRef.current;
122
- const item = current?.items.find((turn) => turn.id === turnId);
123
- if (!current || !item) return false;
124
- const result = await run(() =>
125
- client.cancelQueueItem(workspaceId, sessionId, turnId, {
168
+ const turn = current?.items.find((candidate) => candidate.id === turnId);
169
+ if (!current || !turn) return null;
170
+ pendingRef.current = { ...pendingRef.current, [turnId]: kind };
171
+ setPendingByTurn(pendingRef.current);
172
+ setMutationError(null);
173
+ try {
174
+ const result = await command(current, turn);
175
+ acceptSnapshot(result.snapshot);
176
+ return result;
177
+ } catch (cause) {
178
+ setMutationError(asError(cause));
179
+ await load();
180
+ return null;
181
+ } finally {
182
+ if (turnId in pendingRef.current) {
183
+ const next = { ...pendingRef.current };
184
+ delete next[turnId];
185
+ pendingRef.current = next;
186
+ setPendingByTurn(next);
187
+ }
188
+ }
189
+ },
190
+ [acceptSnapshot, load, sessionId],
191
+ );
192
+
193
+ const moveTurn = useCallback(
194
+ async (turnId: string, beforeTurnId: string | null): Promise<boolean> => {
195
+ const result = await mutate(turnId, "move", (current) =>
196
+ client.moveQueueItem(workspaceId, sessionId!, turnId, {
197
+ clientEventId: operationKey(),
126
198
  expectedQueueVersion: current.version,
127
- expectedItemVersion: item.version,
199
+ beforeTurnId,
128
200
  }),
129
201
  );
130
- if (!result) {
131
- void load();
132
- return false;
133
- }
134
- replaceSnapshot(result.snapshot);
135
- return true;
202
+ return result !== null;
203
+ },
204
+ [client, mutate, sessionId, workspaceId],
205
+ );
206
+
207
+ const editTurn = useCallback(
208
+ async (
209
+ turnId: string,
210
+ edit: { expectedDraftRevision: number; replaceDraft: boolean },
211
+ ): Promise<ComposerDraft | null> => {
212
+ const result = await mutate(turnId, "edit", (_current, turn) =>
213
+ client.editQueueItem(workspaceId, sessionId!, turnId, {
214
+ clientEventId: operationKey(),
215
+ expectedTurnVersion: turn.version,
216
+ expectedDraftRevision: edit.expectedDraftRevision,
217
+ replaceDraft: edit.replaceDraft,
218
+ }),
219
+ );
220
+ return result?.draft ?? null;
221
+ },
222
+ [client, mutate, sessionId, workspaceId],
223
+ );
224
+
225
+ const steerTurn = useCallback(
226
+ async (turnId: string): Promise<boolean> => {
227
+ const result = await mutate(turnId, "steer", (current, turn) =>
228
+ client.steerQueueItem(workspaceId, sessionId!, turnId, {
229
+ clientEventId: operationKey(),
230
+ expectedTurnVersion: turn.version,
231
+ controlEtag: current.effectiveControl.controlEtag,
232
+ }),
233
+ );
234
+ return result !== null;
235
+ },
236
+ [client, mutate, sessionId, workspaceId],
237
+ );
238
+
239
+ const removeTurn = useCallback(
240
+ async (turnId: string): Promise<boolean> => {
241
+ const result = await mutate(turnId, "delete", (_current, turn) =>
242
+ client.deleteQueueItem(workspaceId, sessionId!, turnId, {
243
+ clientEventId: operationKey(),
244
+ expectedTurnVersion: turn.version,
245
+ reason: "Deleted from the prompt queue",
246
+ }),
247
+ );
248
+ return result !== null;
136
249
  },
137
- [client, workspaceId, sessionId, run, load, replaceSnapshot],
250
+ [client, mutate, sessionId, workspaceId],
251
+ );
252
+
253
+ const mutationFor = useCallback(
254
+ (turnId: string): QueueMutationKind | null => pendingByTurn[turnId] ?? null,
255
+ [pendingByTurn],
138
256
  );
257
+ const mutating = useMemo(() => Object.keys(pendingByTurn).length > 0, [pendingByTurn]);
139
258
 
140
259
  return {
141
260
  snapshot,
142
261
  queue: snapshot?.items ?? [],
143
- controlState: snapshot?.controlState ?? null,
144
- controlGeneration: snapshot?.controlGeneration ?? null,
145
- workspaceInferenceState: snapshot?.workspaceInferenceState ?? null,
146
- workspaceInferenceGeneration: snapshot?.workspaceInferenceGeneration ?? null,
147
- workspaceRunExceptionGeneration: snapshot?.workspaceRunExceptionGeneration ?? null,
262
+ effectiveControl: snapshot?.effectiveControl ?? null,
148
263
  loading,
149
264
  error,
150
265
  refresh: load,
266
+ moveTurn,
267
+ editTurn,
268
+ steerTurn,
151
269
  removeTurn,
270
+ pendingByTurn,
271
+ mutationFor,
152
272
  mutating,
153
273
  mutationError,
154
- clearMutationError,
274
+ clearMutationError: useCallback(() => setMutationError(null), []),
155
275
  };
156
276
  }
277
+
278
+ function operationKey(): string {
279
+ return globalThis.crypto.randomUUID();
280
+ }
281
+
282
+ function asError(cause: unknown): Error {
283
+ return cause instanceof Error ? cause : new Error(String(cause));
284
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,6 @@
1
+ // oxlint-disable-next-line typescript/triple-slash-reference -- package consumers must load the ambient type for this optional untyped peer without emitting a runtime import.
2
+ /// <reference path="./types/external.d.ts" />
3
+
1
4
  // @opengeni/react — hooks + styled components on @opengeni/sdk.
2
5
  //
3
6
  // Import the styles once in your Tailwind entry CSS:
@@ -20,15 +23,11 @@ export type {
20
23
  export {
21
24
  useComposer,
22
25
  composeSendInput,
26
+ shouldSteerOnKey,
23
27
  shouldSubmitOnKey,
24
28
  FILE_ONLY_MESSAGE_TEXT,
25
29
  } from "./hooks/use-composer";
26
- export type {
27
- ComposerMode,
28
- ComposerSendExtras,
29
- ComposerState,
30
- UseComposerOptions,
31
- } from "./hooks/use-composer";
30
+ export type { ComposerSendExtras, ComposerState, UseComposerOptions } from "./hooks/use-composer";
32
31
  export { useFileAttachments } from "./hooks/use-file-attachments";
33
32
  export type {
34
33
  FileAttachment,
@@ -36,7 +35,14 @@ export type {
36
35
  UseFileAttachmentsResult,
37
36
  } from "./hooks/use-file-attachments";
38
37
  export { useTurnQueue, isTurnQueueEvent } from "./hooks/use-turn-queue";
39
- export type { UseTurnQueueOptions, UseTurnQueueResult } from "./hooks/use-turn-queue";
38
+ export type {
39
+ QueueMutationKind,
40
+ UseTurnQueueOptions,
41
+ UseTurnQueueResult,
42
+ } from "./hooks/use-turn-queue";
43
+ export { QueueSurface } from "./components/queue-surface";
44
+ export type { QueueSurfaceProps } from "./components/queue-surface";
45
+ export { OPEN_WORKSTREAM_CONTROL_EVENT } from "./components/chat-composer";
40
46
  export { useGoal, isGoalEvent } from "./hooks/use-goal";
41
47
  export type { UseGoalOptions, UseGoalResult } from "./hooks/use-goal";
42
48
  export { useSessionControl } from "./hooks/use-session-control";
package/src/machines.ts CHANGED
@@ -34,6 +34,22 @@ export { MachineMetrics } from "./components/machine-metrics";
34
34
  export type { MachineMetricsProps } from "./components/machine-metrics";
35
35
  export { MachineCard } from "./components/machine-card";
36
36
  export type { MachineCardProps } from "./components/machine-card";
37
+ export { MachineHealthPill } from "./components/machine-health-pill";
38
+ export type { MachineHealthPillProps } from "./components/machine-health-pill";
39
+ // Telemetry: fused health signal + history charts + the per-machine detail view.
40
+ export { deriveHealth, HEALTH_TOKEN, healthPulses } from "./components/machines/health";
41
+ export type { HealthLevel, HealthVerdict } from "./components/machines/health";
42
+ export { MetricSparkline } from "./components/machines/metric-sparkline";
43
+ export type { MetricSparklineProps } from "./components/machines/metric-sparkline";
44
+ export { MetricHistoryChart } from "./components/machines/metric-history-chart";
45
+ export type {
46
+ MetricHistoryChartProps,
47
+ SeriesPoint,
48
+ } from "./components/machines/metric-history-chart";
49
+ export { MachineDetail } from "./components/machines/machine-detail";
50
+ export type { MachineDetailProps } from "./components/machines/machine-detail";
51
+ export { METRICS, METRIC_WINDOWS, WINDOW_LABEL, pointsFor } from "./components/machines/series";
52
+ export type { MetricDef, MetricKey, MetricWindow } from "./components/machines/series";
37
53
  export { MachinesDashboard } from "./components/machines-dashboard";
38
54
  export type { MachinesDashboardProps } from "./components/machines-dashboard";
39
55
  export { MachineDockBar, SharedMachineDisclosure } from "./components/machine-dock-bar";
package/src/provider.tsx CHANGED
@@ -1,16 +1,44 @@
1
- import { createContext, useContext, useMemo, type ReactNode } from "react";
1
+ import {
2
+ OpenGeniApiContractMismatchError,
3
+ OPENGENI_API_CONTRACT_REVISION,
4
+ type StreamConnectionState,
5
+ type WorkspaceControlEvent,
6
+ } from "@opengeni/sdk";
7
+ import {
8
+ createContext,
9
+ useCallback,
10
+ useContext,
11
+ useEffect,
12
+ useMemo,
13
+ useRef,
14
+ useState,
15
+ type ReactNode,
16
+ } from "react";
2
17
  import type { SessionClientLike } from "./client";
3
18
 
4
19
  export type OpenGeniContextValue = {
5
20
  client: SessionClientLike;
6
21
  workspaceId: string;
22
+ workspaceControlEvent: WorkspaceControlEvent | null;
23
+ workspaceControlConnectionState: StreamConnectionState | "idle" | "error";
24
+ registerSessionReconciler: (
25
+ sessionId: string,
26
+ key: string,
27
+ reconcile: () => Promise<void>,
28
+ ) => () => void;
29
+ reconcileSession: (sessionId: string) => Promise<void>;
7
30
  };
8
31
 
9
32
  const OpenGeniContext = createContext<OpenGeniContextValue | null>(null);
33
+ const NOOP_REGISTER_RECONCILER: OpenGeniContextValue["registerSessionReconciler"] = () => () =>
34
+ undefined;
35
+ const NOOP_RECONCILE_SESSION: OpenGeniContextValue["reconcileSession"] = async () => undefined;
36
+ const CONTRACT_RELOAD_STORAGE_PREFIX = "opengeni.reloadForApiContract:";
10
37
 
11
38
  export type OpenGeniProviderProps = {
12
39
  client: SessionClientLike;
13
40
  workspaceId: string;
41
+ onWorkspaceControlEvent?: ((event: WorkspaceControlEvent) => void) | undefined;
14
42
  children?: ReactNode;
15
43
  };
16
44
 
@@ -18,9 +46,161 @@ export type OpenGeniProviderProps = {
18
46
  * Supplies the OpenGeni client + workspace to all hooks below it. Hooks also
19
47
  * accept `{ client, workspaceId }` overrides per call for multi-workspace UIs.
20
48
  */
21
- export function OpenGeniProvider({ client, workspaceId, children }: OpenGeniProviderProps) {
22
- const value = useMemo(() => ({ client, workspaceId }), [client, workspaceId]);
23
- return <OpenGeniContext.Provider value={value}>{children}</OpenGeniContext.Provider>;
49
+ export function OpenGeniProvider({
50
+ client,
51
+ workspaceId,
52
+ onWorkspaceControlEvent,
53
+ children,
54
+ }: OpenGeniProviderProps) {
55
+ const [workspaceControlEvent, setWorkspaceControlEvent] = useState<WorkspaceControlEvent | null>(
56
+ null,
57
+ );
58
+ const [workspaceControlConnectionState, setWorkspaceControlConnectionState] = useState<
59
+ StreamConnectionState | "idle" | "error"
60
+ >("idle");
61
+ const [contractMismatch, setContractMismatch] = useState<OpenGeniApiContractMismatchError | null>(
62
+ null,
63
+ );
64
+ const callbackRef = useRef(onWorkspaceControlEvent);
65
+ const reconcilersRef = useRef(new Map<string, Map<string, () => Promise<void>>>());
66
+ callbackRef.current = onWorkspaceControlEvent;
67
+
68
+ const verifyApiContract = useCallback(async (): Promise<void> => {
69
+ try {
70
+ const config = await client.getClientConfig();
71
+ if (config.apiContractRevision !== OPENGENI_API_CONTRACT_REVISION) {
72
+ throw new OpenGeniApiContractMismatchError(
73
+ OPENGENI_API_CONTRACT_REVISION,
74
+ String(config.apiContractRevision || "(missing)"),
75
+ );
76
+ }
77
+ } catch (error) {
78
+ if (error instanceof OpenGeniApiContractMismatchError) {
79
+ setContractMismatch(error);
80
+ reloadForContractMismatchOnce(error);
81
+ }
82
+ throw error;
83
+ }
84
+ }, [client]);
85
+
86
+ const registerSessionReconciler = useMemo(
87
+ () =>
88
+ (sessionId: string, key: string, reconcile: () => Promise<void>): (() => void) => {
89
+ const sessionReconcilers = reconcilersRef.current.get(sessionId) ?? new Map();
90
+ sessionReconcilers.set(key, reconcile);
91
+ reconcilersRef.current.set(sessionId, sessionReconcilers);
92
+ return () => {
93
+ const current = reconcilersRef.current.get(sessionId);
94
+ current?.delete(key);
95
+ if (current?.size === 0) reconcilersRef.current.delete(sessionId);
96
+ };
97
+ },
98
+ [],
99
+ );
100
+ const reconcileSession = useMemo(
101
+ () =>
102
+ async (sessionId: string): Promise<void> => {
103
+ // This read also crosses the exact API-contract handshake before stale
104
+ // state can be presented as live after a deployment.
105
+ await verifyApiContract();
106
+ const callbacks = [...(reconcilersRef.current.get(sessionId)?.values() ?? [])];
107
+ await Promise.all(callbacks.map((reconcile) => reconcile()));
108
+ },
109
+ [verifyApiContract],
110
+ );
111
+
112
+ useEffect(() => {
113
+ const controller = new AbortController();
114
+ setWorkspaceControlEvent(null);
115
+ setWorkspaceControlConnectionState("connecting");
116
+ void (async () => {
117
+ try {
118
+ await verifyApiContract();
119
+ const workspace = await client.getWorkspace(workspaceId);
120
+ const stream = client.streamWorkspaceControlEvents(workspaceId, {
121
+ after: workspace.inferenceControl.revision,
122
+ signal: controller.signal,
123
+ onStateChange: setWorkspaceControlConnectionState,
124
+ });
125
+ for await (const event of stream) {
126
+ if (controller.signal.aborted) return;
127
+ setWorkspaceControlEvent((current) =>
128
+ !current || event.sequence > current.sequence ? event : current,
129
+ );
130
+ callbackRef.current?.(event);
131
+ }
132
+ } catch (error) {
133
+ if (error instanceof OpenGeniApiContractMismatchError) {
134
+ setContractMismatch(error);
135
+ reloadForContractMismatchOnce(error);
136
+ }
137
+ if (!controller.signal.aborted) setWorkspaceControlConnectionState("error");
138
+ }
139
+ })();
140
+ return () => controller.abort();
141
+ }, [client, verifyApiContract, workspaceId]);
142
+
143
+ const value = useMemo(
144
+ () => ({
145
+ client,
146
+ workspaceId,
147
+ workspaceControlEvent,
148
+ workspaceControlConnectionState,
149
+ registerSessionReconciler,
150
+ reconcileSession,
151
+ }),
152
+ [
153
+ client,
154
+ workspaceId,
155
+ workspaceControlEvent,
156
+ workspaceControlConnectionState,
157
+ registerSessionReconciler,
158
+ reconcileSession,
159
+ ],
160
+ );
161
+ return (
162
+ <OpenGeniContext.Provider value={value}>
163
+ {children}
164
+ {contractMismatch ? <ApiContractMismatchScreen mismatch={contractMismatch} /> : null}
165
+ </OpenGeniContext.Provider>
166
+ );
167
+ }
168
+
169
+ function ApiContractMismatchScreen({ mismatch }: { mismatch: OpenGeniApiContractMismatchError }) {
170
+ return (
171
+ <div
172
+ className="og-root fixed inset-0 z-[2147483647] grid place-items-center bg-og-bg/95 p-6 backdrop-blur-sm"
173
+ role="alert"
174
+ aria-live="assertive"
175
+ data-opengeni-api-contract-mismatch
176
+ >
177
+ <div className="w-full max-w-md rounded-xl border border-og-border bg-og-surface p-6 shadow-2xl">
178
+ <p className="text-sm font-semibold text-og-fg">OpenGeni updated</p>
179
+ <p className="mt-2 text-sm leading-6 text-og-muted">
180
+ This tab cannot safely continue with the new server version. Reload it before sending or
181
+ controlling work.
182
+ </p>
183
+ <p className="mt-3 font-mono text-xs text-og-subtle">
184
+ Client {mismatch.expected} · API {mismatch.actual}
185
+ </p>
186
+ <button
187
+ type="button"
188
+ className="mt-5 inline-flex h-9 items-center rounded-md bg-og-fg px-3 text-sm font-medium text-og-bg"
189
+ onClick={() => window.location.reload()}
190
+ >
191
+ Reload now
192
+ </button>
193
+ </div>
194
+ </div>
195
+ );
196
+ }
197
+
198
+ function reloadForContractMismatchOnce(mismatch: OpenGeniApiContractMismatchError): void {
199
+ if (typeof window === "undefined" || typeof sessionStorage === "undefined") return;
200
+ const key = `${CONTRACT_RELOAD_STORAGE_PREFIX}${mismatch.actual}`;
201
+ if (sessionStorage.getItem(key) === OPENGENI_API_CONTRACT_REVISION) return;
202
+ sessionStorage.setItem(key, OPENGENI_API_CONTRACT_REVISION);
203
+ window.setTimeout(() => window.location.reload(), 150);
24
204
  }
25
205
 
26
206
  export type ClientOverride = {
@@ -38,7 +218,14 @@ export function useOpenGeni(override: ClientOverride = {}): OpenGeniContextValue
38
218
  "@opengeni/react: no OpenGeni client/workspace available. Wrap the tree in <OpenGeniProvider> or pass { client, workspaceId } to the hook.",
39
219
  );
40
220
  }
41
- return { client, workspaceId };
221
+ return {
222
+ client,
223
+ workspaceId,
224
+ workspaceControlEvent: context?.workspaceControlEvent ?? null,
225
+ workspaceControlConnectionState: context?.workspaceControlConnectionState ?? "idle",
226
+ registerSessionReconciler: context?.registerSessionReconciler ?? NOOP_REGISTER_RECONCILER,
227
+ reconcileSession: context?.reconcileSession ?? NOOP_RECONCILE_SESSION,
228
+ };
42
229
  }
43
230
 
44
231
  /**