@opengeni/react 1.0.1 → 2.2.0-canary.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 (174) hide show
  1. package/README.md +33 -10
  2. package/dist/artifacts-spreadsheet.js +1 -1
  3. package/dist/artifacts.js +1 -1
  4. package/dist/browser-viewer-GXUICIAG.js +14 -0
  5. package/dist/{chunk-R3AZOAB5.js → chunk-4MDLEHXL.js} +9 -115
  6. package/dist/chunk-4MDLEHXL.js.map +1 -0
  7. package/dist/{chunk-Y7OEDUJ5.js → chunk-5RZN3DD5.js} +1014 -420
  8. package/dist/chunk-5RZN3DD5.js.map +1 -0
  9. package/dist/chunk-5YK2ZDIG.js +116 -0
  10. package/dist/chunk-5YK2ZDIG.js.map +1 -0
  11. package/dist/chunk-H5YGYNXS.js +329 -0
  12. package/dist/chunk-H5YGYNXS.js.map +1 -0
  13. package/dist/{chunk-7WCLAEFV.js → chunk-ICA6R6TW.js} +2 -2
  14. package/dist/{chunk-7WCLAEFV.js.map → chunk-ICA6R6TW.js.map} +1 -1
  15. package/dist/{chunk-IGC6A2ZI.js → chunk-JOAVA2TW.js} +589 -217
  16. package/dist/chunk-JOAVA2TW.js.map +1 -0
  17. package/dist/{chunk-5UQPNLIO.js → chunk-LTCHJ4MS.js} +9 -9
  18. package/dist/chunk-LTCHJ4MS.js.map +1 -0
  19. package/dist/{chunk-C5ZPSSS5.js → chunk-NEXLLTP4.js} +2 -1
  20. package/dist/{chunk-C5ZPSSS5.js.map → chunk-NEXLLTP4.js.map} +1 -1
  21. package/dist/chunk-QGBJ6GHG.js +2300 -0
  22. package/dist/chunk-QGBJ6GHG.js.map +1 -0
  23. package/dist/{chunk-B6WPZSTT.js → chunk-UOLJWT5Y.js} +78 -16
  24. package/dist/chunk-UOLJWT5Y.js.map +1 -0
  25. package/dist/{chunk-MKPIAGVL.js → chunk-W6NFJXSA.js} +8 -19
  26. package/dist/chunk-W6NFJXSA.js.map +1 -0
  27. package/dist/{chunk-XBZWOM2K.js → chunk-X4A66EWZ.js} +8 -3
  28. package/dist/chunk-X4A66EWZ.js.map +1 -0
  29. package/dist/chunk-X4JAFD5R.js +3907 -0
  30. package/dist/chunk-X4JAFD5R.js.map +1 -0
  31. package/dist/{chunk-6KLX2RH5.js → chunk-XSA2QAKP.js} +334 -19
  32. package/dist/chunk-XSA2QAKP.js.map +1 -0
  33. package/dist/{chunk-HA46CYQK.js → chunk-XXWH2JLC.js} +792 -206
  34. package/dist/chunk-XXWH2JLC.js.map +1 -0
  35. package/dist/client.d.ts +2 -2
  36. package/dist/components/browser-viewer.d.ts +7 -1
  37. package/dist/components/computer-viewer.d.ts +4 -1
  38. package/dist/components/enrollment-consent.d.ts +6 -3
  39. package/dist/components/file-browser.d.ts +7 -1
  40. package/dist/components/human-input-form.d.ts +1 -1
  41. package/dist/components/human-input-surface.d.ts +1 -1
  42. package/dist/components/machine-card.d.ts +4 -1
  43. package/dist/components/machines/machine-detail.d.ts +4 -1
  44. package/dist/components/machines/machine-operation-policy.d.ts +11 -0
  45. package/dist/components/machines-dashboard.d.ts +3 -1
  46. package/dist/components/markdown.d.ts +20 -1
  47. package/dist/components/message-timeline.d.ts +1 -1
  48. package/dist/components/model-policy-picker.d.ts +2 -0
  49. package/dist/components/pierre-file.d.ts +1 -1
  50. package/dist/components/sandbox-files.d.ts +8 -2
  51. package/dist/components/sandbox-workspace.d.ts +33 -3
  52. package/dist/components/session-chrome.d.ts +15 -3
  53. package/dist/components/workspace-dock.d.ts +6 -2
  54. package/dist/composer.js +5 -5
  55. package/dist/computer-viewer-ZOP4Q7A7.js +16 -0
  56. package/dist/computer-viewer-ZOP4Q7A7.js.map +1 -0
  57. package/dist/hooks/channel-cache.d.ts +23 -0
  58. package/dist/hooks/shared-machines-list.d.ts +14 -0
  59. package/dist/hooks/use-attached-browsers.d.ts +2 -1
  60. package/dist/hooks/use-channels.d.ts +1 -0
  61. package/dist/hooks/use-composer.d.ts +37 -9
  62. package/dist/hooks/use-machine-chip.d.ts +3 -3
  63. package/dist/hooks/use-machines.d.ts +19 -1
  64. package/dist/hooks/use-sandbox-files.d.ts +1 -0
  65. package/dist/hooks/use-session-background-commands.d.ts +14 -0
  66. package/dist/hooks/use-turn-queue.d.ts +12 -0
  67. package/dist/hooks/use-workspace-sessions.d.ts +1 -0
  68. package/dist/index.d.ts +7 -5
  69. package/dist/index.js +1096 -623
  70. package/dist/index.js.map +1 -1
  71. package/dist/interaction.js +29 -181
  72. package/dist/interaction.js.map +1 -1
  73. package/dist/lib/interaction-errors.d.ts +3 -0
  74. package/dist/machines.d.ts +4 -2
  75. package/dist/machines.js +10 -2
  76. package/dist/model-policy.d.ts +1 -1
  77. package/dist/model-policy.js +1 -1
  78. package/dist/platform-activity-row-BJHUHXAT.js +169 -0
  79. package/dist/platform-activity-row-BJHUHXAT.js.map +1 -0
  80. package/dist/{queue-surface-implementation-2KVJTYXH.js → queue-surface-implementation-BSKUWHRI.js} +76 -16
  81. package/dist/queue-surface-implementation-BSKUWHRI.js.map +1 -0
  82. package/dist/realtime/realtime-control.d.ts +6 -3
  83. package/dist/realtime.js +36 -14
  84. package/dist/realtime.js.map +1 -1
  85. package/dist/session-ui.d.ts +1 -1
  86. package/dist/session-ui.js +6 -2
  87. package/dist/session.d.ts +2 -2
  88. package/dist/session.js +4 -4
  89. package/dist/timeline/index.d.ts +1 -1
  90. package/dist/timeline/platform-activity-row.d.ts +15 -0
  91. package/dist/timeline/screenshot-lightbox.d.ts +1 -1
  92. package/dist/timeline/shared.d.ts +4 -2
  93. package/dist/timeline/types.d.ts +48 -5
  94. package/dist/types/machines.d.ts +2 -2
  95. package/package.json +3 -3
  96. package/src/client.ts +12 -1
  97. package/src/components/artifacts/editable-spreadsheet.tsx +1 -1
  98. package/src/components/browser-viewer.tsx +361 -58
  99. package/src/components/chat-composer.tsx +1 -1
  100. package/src/components/composer.tsx +6 -6
  101. package/src/components/computer-viewer.tsx +368 -115
  102. package/src/components/enrollment-consent.tsx +64 -6
  103. package/src/components/file-browser.tsx +117 -2
  104. package/src/components/human-input-form.tsx +91 -55
  105. package/src/components/human-input-surface.tsx +1 -1
  106. package/src/components/machine-card.tsx +111 -0
  107. package/src/components/machine-input-display.ts +15 -0
  108. package/src/components/machines/machine-detail.tsx +47 -0
  109. package/src/components/machines/machine-operation-policy.tsx +179 -0
  110. package/src/components/machines-dashboard.tsx +6 -0
  111. package/src/components/markdown.tsx +174 -20
  112. package/src/components/message-timeline.tsx +162 -17
  113. package/src/components/model-policy-picker.tsx +9 -17
  114. package/src/components/pierre-file.tsx +1 -1
  115. package/src/components/queue-surface-implementation.tsx +82 -16
  116. package/src/components/sandbox-files.tsx +135 -19
  117. package/src/components/sandbox-workspace.tsx +204 -92
  118. package/src/components/session-chrome.tsx +415 -208
  119. package/src/components/session-status.tsx +1 -1
  120. package/src/components/user-message-body.tsx +168 -48
  121. package/src/components/workspace-dock.tsx +141 -175
  122. package/src/hooks/channel-cache.ts +104 -0
  123. package/src/hooks/shared-machines-list.ts +239 -0
  124. package/src/hooks/use-attached-browsers.ts +5 -1
  125. package/src/hooks/use-browser-frame-stream.ts +6 -1
  126. package/src/hooks/use-browser-session.ts +6 -4
  127. package/src/hooks/use-browser-sessions.ts +7 -1
  128. package/src/hooks/use-channels.ts +32 -10
  129. package/src/hooks/use-composer.ts +760 -291
  130. package/src/hooks/use-computer-frame-stream.ts +14 -9
  131. package/src/hooks/use-computer-session.ts +16 -14
  132. package/src/hooks/use-computer-sessions.ts +8 -1
  133. package/src/hooks/use-last-started-turn-policy.ts +1 -1
  134. package/src/hooks/use-machine-chip.ts +23 -6
  135. package/src/hooks/use-machines.ts +104 -6
  136. package/src/hooks/use-sandbox-files.ts +195 -140
  137. package/src/hooks/use-sandbox-git.ts +58 -29
  138. package/src/hooks/use-session-background-commands.ts +56 -0
  139. package/src/hooks/use-session-capabilities.ts +18 -0
  140. package/src/hooks/use-turn-queue.ts +111 -12
  141. package/src/hooks/use-workspace-sessions.ts +5 -1
  142. package/src/index.ts +26 -4
  143. package/src/lib/interaction-errors.ts +18 -0
  144. package/src/machines.ts +9 -1
  145. package/src/model-policy.ts +10 -2
  146. package/src/realtime/realtime-control.tsx +53 -17
  147. package/src/session-context.ts +1 -0
  148. package/src/session-ui.ts +6 -1
  149. package/src/session.ts +9 -1
  150. package/src/timeline/activity-rail.tsx +20 -75
  151. package/src/timeline/index.ts +4 -0
  152. package/src/timeline/platform-activity-row.tsx +201 -0
  153. package/src/timeline/projection.ts +476 -23
  154. package/src/timeline/screenshot-lightbox.tsx +48 -14
  155. package/src/timeline/shared.tsx +8 -2
  156. package/src/timeline/tool-renderers.tsx +104 -6
  157. package/src/timeline/turn-summary.tsx +6 -1
  158. package/src/timeline/types.ts +75 -2
  159. package/src/types/machines.ts +10 -0
  160. package/styles/compiled.css +115 -104
  161. package/dist/chunk-5UQPNLIO.js.map +0 -1
  162. package/dist/chunk-6KLX2RH5.js.map +0 -1
  163. package/dist/chunk-B6WPZSTT.js.map +0 -1
  164. package/dist/chunk-HA46CYQK.js.map +0 -1
  165. package/dist/chunk-IGC6A2ZI.js.map +0 -1
  166. package/dist/chunk-LLOLUHVG.js +0 -5937
  167. package/dist/chunk-LLOLUHVG.js.map +0 -1
  168. package/dist/chunk-MKPIAGVL.js.map +0 -1
  169. package/dist/chunk-R3AZOAB5.js.map +0 -1
  170. package/dist/chunk-XBZWOM2K.js.map +0 -1
  171. package/dist/chunk-Y7OEDUJ5.js.map +0 -1
  172. package/dist/interactive-workbench-viewers-TOHBXJEE.js +0 -15
  173. package/dist/queue-surface-implementation-2KVJTYXH.js.map +0 -1
  174. /package/dist/{interactive-workbench-viewers-TOHBXJEE.js.map → browser-viewer-GXUICIAG.js.map} +0 -0
@@ -0,0 +1,239 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import type { MachinesResponse } from "../types/machines";
3
+ import { usePageLiveActivity } from "./internal";
4
+
5
+ export const EMPTY_MACHINES: MachinesResponse = {
6
+ activeSandboxId: null,
7
+ activeEpoch: 0,
8
+ machines: [],
9
+ };
10
+
11
+ export type SharedMachinesSnapshot = {
12
+ data: MachinesResponse | null;
13
+ loading: boolean;
14
+ error: Error | null;
15
+ };
16
+
17
+ type Subscriber = {
18
+ load: (signal?: AbortSignal) => Promise<MachinesResponse>;
19
+ pollIntervalMs: number | undefined;
20
+ enabled: boolean;
21
+ pageLive: boolean;
22
+ onChange: () => void;
23
+ };
24
+
25
+ type Share = {
26
+ snapshot: SharedMachinesSnapshot;
27
+ subscribers: Set<Subscriber>;
28
+ timer: ReturnType<typeof setTimeout> | null;
29
+ disposeTimer: ReturnType<typeof setTimeout> | null;
30
+ abort: AbortController | null;
31
+ inFlight: Promise<void> | null;
32
+ trailing: Promise<void> | null;
33
+ generation: number;
34
+ };
35
+
36
+ const shares = new Map<string, Share>();
37
+
38
+ function emit(share: Share): void {
39
+ for (const subscriber of share.subscribers) {
40
+ subscriber.onChange();
41
+ }
42
+ }
43
+
44
+ function effective(share: Share): { enabled: boolean; pollIntervalMs: number | undefined } {
45
+ let enabled = false;
46
+ let pollIntervalMs: number | undefined;
47
+ for (const subscriber of share.subscribers) {
48
+ if (!subscriber.enabled || !subscriber.pageLive) continue;
49
+ enabled = true;
50
+ if (subscriber.pollIntervalMs !== undefined && subscriber.pollIntervalMs > 0) {
51
+ pollIntervalMs =
52
+ pollIntervalMs === undefined
53
+ ? subscriber.pollIntervalMs
54
+ : Math.min(pollIntervalMs, subscriber.pollIntervalMs);
55
+ }
56
+ }
57
+ return { enabled, pollIntervalMs };
58
+ }
59
+
60
+ function latestLoad(share: Share): ((signal?: AbortSignal) => Promise<MachinesResponse>) | null {
61
+ let load: ((signal?: AbortSignal) => Promise<MachinesResponse>) | null = null;
62
+ for (const subscriber of share.subscribers) {
63
+ if (subscriber.enabled && subscriber.pageLive) load = subscriber.load;
64
+ }
65
+ return load;
66
+ }
67
+
68
+ function stopTimer(share: Share): void {
69
+ if (share.timer !== null) {
70
+ clearTimeout(share.timer);
71
+ share.timer = null;
72
+ }
73
+ }
74
+
75
+ function cancelDispose(share: Share): void {
76
+ if (share.disposeTimer !== null) {
77
+ clearTimeout(share.disposeTimer);
78
+ share.disposeTimer = null;
79
+ }
80
+ }
81
+
82
+ function arm(share: Share): void {
83
+ stopTimer(share);
84
+ const { enabled, pollIntervalMs } = effective(share);
85
+ if (!enabled || pollIntervalMs === undefined) return;
86
+ share.timer = setTimeout(() => {
87
+ share.timer = null;
88
+ void run(share).finally(() => arm(share));
89
+ }, pollIntervalMs);
90
+ }
91
+
92
+ async function runFresh(share: Share): Promise<void> {
93
+ const load = latestLoad(share);
94
+ if (!load) return;
95
+ const ticket = ++share.generation;
96
+ const abort = new AbortController();
97
+ share.abort = abort;
98
+ try {
99
+ const data = await load(abort.signal);
100
+ if (ticket !== share.generation || abort.signal.aborted) return;
101
+ share.snapshot = { data, loading: false, error: null };
102
+ emit(share);
103
+ } catch (cause) {
104
+ if (ticket !== share.generation || abort.signal.aborted) return;
105
+ share.snapshot = {
106
+ data: share.snapshot.data,
107
+ loading: false,
108
+ error: cause instanceof Error ? cause : new Error(String(cause)),
109
+ };
110
+ emit(share);
111
+ } finally {
112
+ if (share.abort === abort) share.abort = null;
113
+ }
114
+ }
115
+
116
+ function run(share: Share, force = false): Promise<void> {
117
+ if (!force && share.inFlight) return share.inFlight;
118
+ if (force && share.inFlight) {
119
+ if (share.trailing) return share.trailing;
120
+ let trailing!: Promise<void>;
121
+ trailing = share.inFlight
122
+ .then(async () => {
123
+ if (!effective(share).enabled) return;
124
+ await run(share);
125
+ })
126
+ .finally(() => {
127
+ if (share.trailing === trailing) share.trailing = null;
128
+ });
129
+ share.trailing = trailing;
130
+ return trailing;
131
+ }
132
+ let promise!: Promise<void>;
133
+ promise = runFresh(share).finally(() => {
134
+ if (share.inFlight === promise) share.inFlight = null;
135
+ });
136
+ share.inFlight = promise;
137
+ return promise;
138
+ }
139
+
140
+ function reconcile(share: Share): void {
141
+ const { enabled } = effective(share);
142
+ if (!enabled) {
143
+ share.generation += 1;
144
+ share.abort?.abort();
145
+ share.abort = null;
146
+ share.inFlight = null;
147
+ stopTimer(share);
148
+ if (share.snapshot.loading) {
149
+ share.snapshot = { ...share.snapshot, loading: false };
150
+ emit(share);
151
+ }
152
+ return;
153
+ }
154
+ if (share.snapshot.data !== null || share.snapshot.error) {
155
+ arm(share);
156
+ return;
157
+ }
158
+ share.snapshot = { ...share.snapshot, loading: true };
159
+ emit(share);
160
+ void run(share).finally(() => arm(share));
161
+ }
162
+
163
+ function getOrCreate(shareKey: string): Share {
164
+ const existing = shares.get(shareKey);
165
+ if (existing) {
166
+ cancelDispose(existing);
167
+ return existing;
168
+ }
169
+ const created: Share = {
170
+ snapshot: { data: null, loading: true, error: null },
171
+ subscribers: new Set(),
172
+ timer: null,
173
+ disposeTimer: null,
174
+ abort: null,
175
+ inFlight: null,
176
+ trailing: null,
177
+ generation: 0,
178
+ };
179
+ shares.set(shareKey, created);
180
+ return created;
181
+ }
182
+
183
+ export async function refreshSharedMachinesList(shareKey: string): Promise<void> {
184
+ const share = shares.get(shareKey);
185
+ if (!share) return;
186
+ await run(share, true);
187
+ }
188
+
189
+ export function useSharedMachinesList(
190
+ shareKey: string,
191
+ load: (signal?: AbortSignal) => Promise<MachinesResponse>,
192
+ options: { pollIntervalMs?: number | undefined; enabled?: boolean | undefined } = {},
193
+ ): SharedMachinesSnapshot & { refresh: () => Promise<void> } {
194
+ const enabled = options.enabled ?? true;
195
+ const pageLive = usePageLiveActivity();
196
+ const [, setRevision] = useState(0);
197
+ const live = shares.get(shareKey)?.snapshot;
198
+ const view = live ?? { data: null, loading: enabled && pageLive, error: null };
199
+
200
+ useEffect(() => {
201
+ const share = getOrCreate(shareKey);
202
+ const subscriber: Subscriber = {
203
+ load,
204
+ pollIntervalMs: options.pollIntervalMs,
205
+ enabled,
206
+ pageLive,
207
+ onChange: () => setRevision((revision) => revision + 1),
208
+ };
209
+ share.subscribers.add(subscriber);
210
+ setRevision((revision) => revision + 1);
211
+ reconcile(share);
212
+ return () => {
213
+ share.subscribers.delete(subscriber);
214
+ if (share.subscribers.size > 0) {
215
+ reconcile(share);
216
+ return;
217
+ }
218
+ // Abort immediately so a session switch does not keep the old GET alive.
219
+ // Keep the snapshot one tick so a same-key remount (poll-interval change)
220
+ // does not flash an empty fleet.
221
+ share.generation += 1;
222
+ share.abort?.abort();
223
+ share.abort = null;
224
+ share.inFlight = null;
225
+ share.trailing = null;
226
+ stopTimer(share);
227
+ cancelDispose(share);
228
+ share.disposeTimer = setTimeout(() => {
229
+ share.disposeTimer = null;
230
+ if (share.subscribers.size === 0 && shares.get(shareKey) === share) {
231
+ shares.delete(shareKey);
232
+ }
233
+ }, 0);
234
+ };
235
+ }, [shareKey, load, options.pollIntervalMs, enabled, pageLive]);
236
+
237
+ const refresh = useCallback(() => refreshSharedMachinesList(shareKey), [shareKey]);
238
+ return { ...view, refresh };
239
+ }
@@ -1,4 +1,4 @@
1
- import type { AttachedBrowserDevice } from "@opengeni/sdk/interaction";
1
+ import type { AttachedBrowserBridge, AttachedBrowserDevice } from "@opengeni/sdk/interaction";
2
2
  import { useCallback, useEffect, useRef, useState } from "react";
3
3
  import type { EmbeddedBrowserInteractionClientLike } from "../client";
4
4
  import {
@@ -15,6 +15,7 @@ export type UseAttachedBrowsersOptions = EmbeddedBrowserInteractionClientOverrid
15
15
 
16
16
  export type UseAttachedBrowsersResult = {
17
17
  revision: number;
18
+ bridges: AttachedBrowserBridge[];
18
19
  devices: AttachedBrowserDevice[];
19
20
  loading: boolean;
20
21
  refreshing: boolean;
@@ -67,6 +68,7 @@ export function useAttachedBrowsers(
67
68
  setState({
68
69
  workspaceId,
69
70
  revision: response.revision,
71
+ bridges: response.bridges,
70
72
  devices: sortDevices(response.devices),
71
73
  loading: false,
72
74
  refreshing: false,
@@ -117,6 +119,7 @@ export function useAttachedBrowsers(
117
119
 
118
120
  return {
119
121
  revision: visible.revision,
122
+ bridges: visible.bridges,
120
123
  devices: visible.devices,
121
124
  loading: visible.loading,
122
125
  refreshing: visible.refreshing,
@@ -129,6 +132,7 @@ function emptyState(workspaceId: string, loading: boolean) {
129
132
  return {
130
133
  workspaceId,
131
134
  revision: 0,
135
+ bridges: [] as AttachedBrowserBridge[],
132
136
  devices: [] as AttachedBrowserDevice[],
133
137
  loading,
134
138
  refreshing: false,
@@ -389,7 +389,7 @@ export function useBrowserFrameStream(
389
389
  connectSocket();
390
390
  } catch (cause) {
391
391
  if (attachmentAbort.signal.aborted || disposed) return;
392
- fail(cause);
392
+ fail(cause, !isPlacementGenerationLossError(cause));
393
393
  }
394
394
  };
395
395
 
@@ -423,3 +423,8 @@ async function messageBytes(value: unknown): Promise<Uint8Array> {
423
423
  }
424
424
  throw new Error("browser frame stream returned a non-binary message");
425
425
  }
426
+
427
+ function isPlacementGenerationLossError(cause: unknown): boolean {
428
+ const message = cause instanceof Error ? cause.message : String(cause);
429
+ return /placement instance changed|controller authority changed/i.test(message);
430
+ }
@@ -16,6 +16,7 @@ import {
16
16
  type EmbeddedBrowserInteractionClientOverride,
17
17
  useEmbeddedBrowserInteraction,
18
18
  } from "../session-context";
19
+ import { isNonRetryableInteractionError } from "../lib/interaction-errors";
19
20
  import { usePageLiveActivity } from "./internal";
20
21
 
21
22
  export type UseBrowserSessionOptions = EmbeddedBrowserInteractionClientOverride & {
@@ -71,6 +72,7 @@ export function useBrowserSession(options: UseBrowserSessionOptions): UseBrowser
71
72
  }>(() => emptyState(browserSessionId, enabled));
72
73
  const visible =
73
74
  state.browserSessionId === browserSessionId ? state : emptyState(browserSessionId, enabled);
75
+ const refreshBlocked = isNonRetryableInteractionError(visible.error);
74
76
  const selectedTargetIdRef = useRef<string | null>(visible.selectedTargetId);
75
77
  selectedTargetIdRef.current = visible.selectedTargetId;
76
78
  const observationRef = useRef<{
@@ -198,15 +200,15 @@ export function useBrowserSession(options: UseBrowserSessionOptions): UseBrowser
198
200
  }, [browserSessionId, enabled, invalidateRefresh, refresh]);
199
201
 
200
202
  useEffect(() => {
201
- if (!enabled || !pageLive || visible.mutating) return;
203
+ if (!enabled || !pageLive || visible.mutating || refreshBlocked) return;
202
204
  const timer = setInterval(() => {
203
205
  if (!requestRef.current.controller) void refresh();
204
206
  }, pollIntervalMs);
205
207
  return () => clearInterval(timer);
206
- }, [enabled, pageLive, pollIntervalMs, refresh, visible.mutating]);
208
+ }, [enabled, pageLive, pollIntervalMs, refresh, refreshBlocked, visible.mutating]);
207
209
 
208
210
  useEffect(() => {
209
- if (!enabled || !browserSessionId || !pageLive) return;
211
+ if (!enabled || !browserSessionId || !pageLive || refreshBlocked) return;
210
212
  let disposed = false;
211
213
  const heartbeat = () => {
212
214
  void client.heartbeatBrowserSession(workspaceId, browserSessionId).catch(() => {
@@ -218,7 +220,7 @@ export function useBrowserSession(options: UseBrowserSessionOptions): UseBrowser
218
220
  disposed = true;
219
221
  clearInterval(timer);
220
222
  };
221
- }, [browserSessionId, client, enabled, pageLive, refresh, workspaceId]);
223
+ }, [browserSessionId, client, enabled, pageLive, refresh, refreshBlocked, workspaceId]);
222
224
 
223
225
  const runMutation = useCallback(
224
226
  async <T>(scopeBrowserSessionId: string, operation: () => Promise<T>): Promise<T> => {
@@ -60,6 +60,7 @@ export function useBrowserSessions(
60
60
  const enabled = options.enabled ?? true;
61
61
  const [state, setState] = useState<{
62
62
  workspaceId: string;
63
+ active: boolean;
63
64
  revision: number;
64
65
  sessions: BrowserSession[];
65
66
  loading: boolean;
@@ -89,6 +90,7 @@ export function useBrowserSessions(
89
90
  requestRef.current = { id, controller };
90
91
  setState((current) => ({
91
92
  ...(current.workspaceId === workspaceId ? current : emptyState(workspaceId, true)),
93
+ active: true,
92
94
  loading: foreground,
93
95
  refreshing: !foreground,
94
96
  error: foreground || current.workspaceId !== workspaceId ? null : current.error,
@@ -100,6 +102,7 @@ export function useBrowserSessions(
100
102
  if (!mountedRef.current || requestRef.current.id !== id) return;
101
103
  setState({
102
104
  workspaceId,
105
+ active: true,
103
106
  revision: response.revision,
104
107
  sessions: sortBrowserSessions(response.sessions),
105
108
  loading: false,
@@ -229,7 +232,9 @@ export function useBrowserSessions(
229
232
  revision: visibleState.revision,
230
233
  sessions: visibleState.sessions,
231
234
  relevantSessions,
232
- loading: visibleState.loading,
235
+ // A disabled registry is deliberately empty. Expose the first re-enabled
236
+ // render as loading so consumers do not reconcile against that empty gap.
237
+ loading: enabled ? !visibleState.active || visibleState.loading : false,
233
238
  refreshing: visibleState.refreshing,
234
239
  error: visibleState.error,
235
240
  refresh,
@@ -243,6 +248,7 @@ export function useBrowserSessions(
243
248
  function emptyState(workspaceId: string, loading: boolean) {
244
249
  return {
245
250
  workspaceId,
251
+ active: false,
246
252
  revision: 0,
247
253
  sessions: [] as BrowserSession[],
248
254
  loading,
@@ -1,6 +1,7 @@
1
1
  import type { Channel, CreateChannelRequest, Session, UpdateChannelRequest } from "@opengeni/sdk";
2
- import { useCallback } from "react";
2
+ import { useCallback, useMemo, useSyncExternalStore } from "react";
3
3
  import { useOpenGeni, type ClientOverride } from "../provider";
4
+ import { channelCacheFor } from "./channel-cache";
4
5
  import { useMutationRunner, usePolledValue } from "./internal";
5
6
 
6
7
  export type UseChannelsOptions = ClientOverride & {
@@ -15,6 +16,7 @@ export type UseChannelsResult = {
15
16
  refresh: () => Promise<void>;
16
17
  create: (request: CreateChannelRequest) => Promise<Channel | null>;
17
18
  update: (channelId: string, request: UpdateChannelRequest) => Promise<Channel | null>;
19
+ reorder: (channelIds: string[]) => Promise<Channel[] | null>;
18
20
  remove: (channelId: string) => Promise<boolean>;
19
21
  /** Re-file a session into a channel; null moves it back to the inbox. */
20
22
  moveSession: (sessionId: string, channelId: string | null) => Promise<Session | null>;
@@ -30,25 +32,33 @@ export type UseChannelsResult = {
30
32
  */
31
33
  export function useChannels(options: UseChannelsOptions = {}): UseChannelsResult {
32
34
  const { client, workspaceId } = useOpenGeni(options);
33
- const load = useCallback(
34
- async () => await client.listChannels(workspaceId),
35
- [client, workspaceId],
36
- );
37
- const { data, loading, error, refresh } = usePolledValue(load, {
35
+ const cache = useMemo(() => channelCacheFor(client, workspaceId), [client, workspaceId]);
36
+ const snapshot = useSyncExternalStore(cache.subscribe, cache.getSnapshot, cache.getSnapshot);
37
+ const load = useCallback(async () => {
38
+ const ticket = cache.beginLoad();
39
+ const channels = await client.listChannels(workspaceId);
40
+ cache.acceptLoad(ticket, channels);
41
+ return channels;
42
+ }, [cache, client, workspaceId]);
43
+ const { loading, error, refresh } = usePolledValue(load, {
38
44
  pollIntervalMs: options.pollIntervalMs,
39
45
  enabled: options.enabled,
40
46
  });
41
- const { run, mutating, mutationError, clearMutationError } = useMutationRunner();
47
+ const { run, mutating, mutationError, clearMutationError } = useMutationRunner(cache);
42
48
 
43
49
  const create = useCallback(
44
50
  async (request: CreateChannelRequest): Promise<Channel | null> => {
45
51
  const result = await run(() => client.createChannel(workspaceId, request));
46
52
  if (result) {
53
+ // Publish the canonical write response before this promise can resolve.
54
+ // The composer may create a session with this id immediately, while a
55
+ // sibling rail hook could still have an older list request in flight.
56
+ cache.publishCreated(result);
47
57
  await refresh();
48
58
  }
49
59
  return result;
50
60
  },
51
- [client, workspaceId, run, refresh],
61
+ [cache, client, workspaceId, run, refresh],
52
62
  );
53
63
 
54
64
  const update = useCallback(
@@ -76,6 +86,15 @@ export function useChannels(options: UseChannelsOptions = {}): UseChannelsResult
76
86
  [client, workspaceId, run, refresh],
77
87
  );
78
88
 
89
+ const reorder = useCallback(
90
+ async (channelIds: string[]): Promise<Channel[] | null> => {
91
+ const result = await run(() => client.reorderChannels(workspaceId, { channelIds }));
92
+ if (result) await refresh();
93
+ return result;
94
+ },
95
+ [client, workspaceId, run, refresh],
96
+ );
97
+
79
98
  const moveSession = useCallback(
80
99
  async (sessionId: string, channelId: string | null): Promise<Session | null> => {
81
100
  return await run(() => client.updateSessionChannel(workspaceId, sessionId, { channelId }));
@@ -84,12 +103,15 @@ export function useChannels(options: UseChannelsOptions = {}): UseChannelsResult
84
103
  );
85
104
 
86
105
  return {
87
- channels: data ?? [],
88
- loading,
106
+ // Cache snapshots replace arrays instead of mutating them, so preserve the
107
+ // stable reference expected from the previous polled-value implementation.
108
+ channels: snapshot.channels as Channel[],
109
+ loading: loading && !snapshot.ready,
89
110
  error,
90
111
  refresh,
91
112
  create,
92
113
  update,
114
+ reorder,
93
115
  remove,
94
116
  moveSession,
95
117
  mutating,