@opengeni/react 0.52.1 → 1.0.1

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 (132) hide show
  1. package/README.md +51 -1
  2. package/dist/artifacts.d.ts +1 -1
  3. package/dist/artifacts.js +43 -14
  4. package/dist/artifacts.js.map +1 -1
  5. package/dist/{chunk-ROIRTM37.js → chunk-5UQPNLIO.js} +6 -6
  6. package/dist/{chunk-JR3QFY6B.js → chunk-6KLX2RH5.js} +49 -2
  7. package/dist/chunk-6KLX2RH5.js.map +1 -0
  8. package/dist/{chunk-FWS3KC3Z.js → chunk-B6WPZSTT.js} +104 -104
  9. package/dist/{chunk-FWS3KC3Z.js.map → chunk-B6WPZSTT.js.map} +1 -1
  10. package/dist/{chunk-HDBG7XGO.js → chunk-C5ZPSSS5.js} +50 -1
  11. package/dist/chunk-C5ZPSSS5.js.map +1 -0
  12. package/dist/{chunk-X4S56Y2H.js → chunk-HA46CYQK.js} +135 -11
  13. package/dist/chunk-HA46CYQK.js.map +1 -0
  14. package/dist/{chunk-724BW4TW.js → chunk-IGC6A2ZI.js} +2 -2
  15. package/dist/chunk-LLOLUHVG.js +5937 -0
  16. package/dist/chunk-LLOLUHVG.js.map +1 -0
  17. package/dist/{chunk-WYA65Y3F.js → chunk-MKPIAGVL.js} +13 -3
  18. package/dist/chunk-MKPIAGVL.js.map +1 -0
  19. package/dist/chunk-R3AZOAB5.js +933 -0
  20. package/dist/chunk-R3AZOAB5.js.map +1 -0
  21. package/dist/{chunk-23EJ676W.js → chunk-XBZWOM2K.js} +9 -4
  22. package/dist/chunk-XBZWOM2K.js.map +1 -0
  23. package/dist/{chunk-JAGDUCZQ.js → chunk-Y7OEDUJ5.js} +17 -13
  24. package/dist/chunk-Y7OEDUJ5.js.map +1 -0
  25. package/dist/client.d.ts +6 -4
  26. package/dist/components/artifacts/index.d.ts +1 -0
  27. package/dist/components/artifacts/published-html-artifact-frame.d.ts +14 -0
  28. package/dist/components/browser-input.d.ts +7 -0
  29. package/dist/components/browser-viewer.d.ts +5 -4
  30. package/dist/components/computer-viewer.d.ts +2 -1
  31. package/dist/components/desktop-viewer.d.ts +26 -2
  32. package/dist/components/interaction-intervention-banner.d.ts +11 -0
  33. package/dist/components/sandbox-terminal.d.ts +4 -1
  34. package/dist/components/sandbox-workspace.d.ts +3 -0
  35. package/dist/composer.js +6 -6
  36. package/dist/hooks/use-auth-runs.d.ts +32 -0
  37. package/dist/hooks/use-browser-downloads.d.ts +25 -0
  38. package/dist/hooks/use-browser-identities.d.ts +5 -1
  39. package/dist/hooks/use-browser-session.d.ts +2 -1
  40. package/dist/hooks/use-browser-sessions.d.ts +1 -1
  41. package/dist/hooks/use-channels.d.ts +26 -0
  42. package/dist/hooks/use-computer-frame-stream.d.ts +1 -1
  43. package/dist/hooks/use-computer-session.d.ts +2 -1
  44. package/dist/hooks/use-computer-sessions.d.ts +1 -1
  45. package/dist/hooks/use-desktop-stream.d.ts +4 -0
  46. package/dist/hooks/use-interaction-interventions.d.ts +27 -0
  47. package/dist/hooks/use-interaction-invalidation.d.ts +19 -0
  48. package/dist/hooks/use-network-routes.d.ts +25 -0
  49. package/dist/hooks/use-site-auth-connections.d.ts +25 -0
  50. package/dist/hooks/use-supergrok-accounts.d.ts +26 -0
  51. package/dist/hooks/use-terminal-stream.d.ts +3 -0
  52. package/dist/index.d.ts +4 -0
  53. package/dist/index.js +1220 -1545
  54. package/dist/index.js.map +1 -1
  55. package/dist/interaction.d.ts +13 -3
  56. package/dist/interaction.js +520 -6
  57. package/dist/interaction.js.map +1 -1
  58. package/dist/interactive-workbench-viewers-TOHBXJEE.js +15 -0
  59. package/dist/lib/host-platform.d.ts +5 -0
  60. package/dist/lib/relay-wire.d.ts +20 -1
  61. package/dist/machines.js +3 -3
  62. package/dist/model-policy.d.ts +1 -1
  63. package/dist/model-policy.js +1 -1
  64. package/dist/provider.d.ts +3 -2
  65. package/dist/realtime/realtime-control.d.ts +6 -2
  66. package/dist/realtime.js +138 -24
  67. package/dist/realtime.js.map +1 -1
  68. package/dist/session-context.d.ts +10 -2
  69. package/dist/session-ui.js +3 -3
  70. package/dist/session.js +11 -11
  71. package/dist/timeline/types.d.ts +5 -0
  72. package/package.json +2 -2
  73. package/src/artifacts.ts +3 -0
  74. package/src/client.ts +73 -3
  75. package/src/components/artifacts/index.ts +5 -0
  76. package/src/components/artifacts/published-html-artifact-frame.tsx +38 -0
  77. package/src/components/browser-input.ts +65 -0
  78. package/src/components/browser-viewer.tsx +1287 -177
  79. package/src/components/computer-viewer.tsx +611 -114
  80. package/src/components/desktop-viewer.tsx +114 -1
  81. package/src/components/interaction-intervention-banner.tsx +99 -0
  82. package/src/components/machines/machine-detail.tsx +1 -1
  83. package/src/components/model-policy-picker.tsx +15 -0
  84. package/src/components/sandbox-terminal.tsx +24 -7
  85. package/src/components/sandbox-workspace.tsx +42 -4
  86. package/src/components/workspace-dock.tsx +59 -5
  87. package/src/hooks/use-attached-browsers.ts +13 -11
  88. package/src/hooks/use-auth-runs.ts +278 -0
  89. package/src/hooks/use-browser-downloads.ts +218 -0
  90. package/src/hooks/use-browser-frame-stream.ts +168 -59
  91. package/src/hooks/use-browser-identities.ts +47 -6
  92. package/src/hooks/use-browser-session.ts +94 -27
  93. package/src/hooks/use-browser-sessions.ts +19 -14
  94. package/src/hooks/use-channels.ts +99 -0
  95. package/src/hooks/use-computer-frame-stream.ts +184 -61
  96. package/src/hooks/use-computer-session.ts +22 -2
  97. package/src/hooks/use-computer-sessions.ts +19 -13
  98. package/src/hooks/use-desktop-stream.ts +39 -7
  99. package/src/hooks/use-interaction-interventions.ts +242 -0
  100. package/src/hooks/use-interaction-invalidation.ts +66 -0
  101. package/src/hooks/use-machine-chip.ts +13 -3
  102. package/src/hooks/use-network-routes.ts +214 -0
  103. package/src/hooks/use-site-auth-connections.ts +226 -0
  104. package/src/hooks/use-supergrok-accounts.ts +81 -0
  105. package/src/hooks/use-terminal-stream.ts +288 -55
  106. package/src/index.ts +8 -0
  107. package/src/interaction.ts +30 -0
  108. package/src/lib/host-platform.ts +10 -0
  109. package/src/lib/relay-wire.ts +43 -4
  110. package/src/lib/terminal-capability.ts +1 -1
  111. package/src/model-policy.ts +19 -4
  112. package/src/provider.tsx +133 -1
  113. package/src/realtime/realtime-control.tsx +181 -26
  114. package/src/session-context.ts +66 -1
  115. package/src/timeline/activity-rail.tsx +6 -0
  116. package/src/timeline/projection.ts +58 -0
  117. package/src/timeline/types.ts +2 -0
  118. package/styles/compiled.css +64 -6
  119. package/dist/chunk-23EJ676W.js.map +0 -1
  120. package/dist/chunk-6BBWWOOU.js +0 -82
  121. package/dist/chunk-6BBWWOOU.js.map +0 -1
  122. package/dist/chunk-7I2KB3OK.js +0 -3895
  123. package/dist/chunk-7I2KB3OK.js.map +0 -1
  124. package/dist/chunk-HDBG7XGO.js.map +0 -1
  125. package/dist/chunk-JAGDUCZQ.js.map +0 -1
  126. package/dist/chunk-JR3QFY6B.js.map +0 -1
  127. package/dist/chunk-WYA65Y3F.js.map +0 -1
  128. package/dist/chunk-X4S56Y2H.js.map +0 -1
  129. package/dist/interactive-workbench-viewers-KHNOHWQE.js +0 -13
  130. /package/dist/{chunk-ROIRTM37.js.map → chunk-5UQPNLIO.js.map} +0 -0
  131. /package/dist/{chunk-724BW4TW.js.map → chunk-IGC6A2ZI.js.map} +0 -0
  132. /package/dist/{interactive-workbench-viewers-KHNOHWQE.js.map → interactive-workbench-viewers-TOHBXJEE.js.map} +0 -0
@@ -5,7 +5,7 @@ import {
5
5
  type EmbeddedBrowserInteractionClientOverride,
6
6
  useEmbeddedBrowserInteraction,
7
7
  } from "../session-context";
8
- import { usePageLiveActivity } from "./internal";
8
+ import { useInteractionInvalidation } from "./use-interaction-invalidation";
9
9
 
10
10
  export type UseAttachedBrowsersOptions = EmbeddedBrowserInteractionClientOverride & {
11
11
  enabled?: boolean | undefined;
@@ -27,11 +27,10 @@ export type UseAttachedBrowsersResult = {
27
27
  export function useAttachedBrowsers(
28
28
  options: UseAttachedBrowsersOptions = {},
29
29
  ): UseAttachedBrowsersResult {
30
- const { client, workspaceId } = useEmbeddedBrowserInteraction(options);
30
+ const { client, workspaceId, workspaceInteractionEvent, workspaceInteractionConnectionState } =
31
+ useEmbeddedBrowserInteraction(options);
31
32
  const enabled = options.enabled ?? true;
32
33
  const includeDisconnected = options.includeDisconnected ?? false;
33
- const pageLive = usePageLiveActivity();
34
- const pollIntervalMs = Math.max(1_000, options.pollIntervalMs ?? 3_000);
35
34
  const [state, setState] = useState(() => emptyState(workspaceId, enabled));
36
35
  const requestRef = useRef<{ id: number; controller: AbortController | null }>({
37
36
  id: 0,
@@ -105,13 +104,16 @@ export function useAttachedBrowsers(
105
104
  };
106
105
  }, [cancelLoad, enabled, load, workspaceId]);
107
106
 
108
- useEffect(() => {
109
- if (!enabled || !pageLive) return;
110
- const timer = setInterval(() => {
111
- if (!requestRef.current.controller) void load(false);
112
- }, pollIntervalMs);
113
- return () => clearInterval(timer);
114
- }, [enabled, load, pageLive, pollIntervalMs]);
107
+ useInteractionInvalidation({
108
+ workspaceId,
109
+ key: `${workspaceId}:${includeDisconnected}`,
110
+ enabled,
111
+ event: workspaceInteractionEvent,
112
+ connectionState: workspaceInteractionConnectionState,
113
+ knownRevision: visible.revision,
114
+ refresh,
115
+ fallbackPollIntervalMs: options.pollIntervalMs,
116
+ });
115
117
 
116
118
  return {
117
119
  revision: visible.revision,
@@ -0,0 +1,278 @@
1
+ import type {
2
+ AuthRun,
3
+ AuthRunMutationResponse,
4
+ ProtectedAuthFillRequest,
5
+ ProtectedAuthFillResponse,
6
+ ReportAuthRunRequest,
7
+ StartAuthRunRequest,
8
+ VerifyAuthRunRequest,
9
+ } from "@opengeni/sdk/interaction";
10
+ import { useCallback, useEffect, useRef, useState } from "react";
11
+ import type { EmbeddedBrowserInteractionClientLike } from "../client";
12
+ import {
13
+ type EmbeddedBrowserInteractionClientOverride,
14
+ useEmbeddedBrowserInteraction,
15
+ } from "../session-context";
16
+ import { useInteractionInvalidation } from "./use-interaction-invalidation";
17
+
18
+ export type UseAuthRunsOptions = EmbeddedBrowserInteractionClientOverride & {
19
+ enabled?: boolean | undefined;
20
+ browserSessionId?: string | null | undefined;
21
+ siteAuthConnectionId?: string | null | undefined;
22
+ includeSettled?: boolean | undefined;
23
+ };
24
+
25
+ export type UseAuthRunsResult = {
26
+ runs: AuthRun[];
27
+ loading: boolean;
28
+ refreshing: boolean;
29
+ mutating: boolean;
30
+ error: Error | null;
31
+ refresh: () => Promise<void>;
32
+ start: (
33
+ browserSessionId: string,
34
+ request: Omit<StartAuthRunRequest, "operationId"> & { operationId?: string },
35
+ ) => Promise<AuthRunMutationResponse>;
36
+ report: (
37
+ browserSessionId: string,
38
+ authRunId: string,
39
+ request: Omit<ReportAuthRunRequest, "operationId"> & { operationId?: string },
40
+ ) => Promise<AuthRunMutationResponse>;
41
+ protectedFill: (
42
+ browserSessionId: string,
43
+ authRunId: string,
44
+ request: Omit<ProtectedAuthFillRequest, "operationId"> & { operationId?: string },
45
+ ) => Promise<ProtectedAuthFillResponse>;
46
+ verify: (
47
+ browserSessionId: string,
48
+ authRunId: string,
49
+ request: Omit<VerifyAuthRunRequest, "operationId"> & { operationId?: string },
50
+ ) => Promise<AuthRunMutationResponse>;
51
+ };
52
+
53
+ /** Live authentication state machines tied to exact browser document generations. */
54
+ export function useAuthRuns(options: UseAuthRunsOptions = {}): UseAuthRunsResult {
55
+ const { client, workspaceId, workspaceInteractionEvent, workspaceInteractionConnectionState } =
56
+ useEmbeddedBrowserInteraction(options);
57
+ const enabled = options.enabled ?? true;
58
+ const browserSessionId = options.browserSessionId ?? null;
59
+ const siteAuthConnectionId = options.siteAuthConnectionId ?? null;
60
+ const includeSettled = options.includeSettled ?? false;
61
+ const key = `${workspaceId}:${browserSessionId ?? "*"}:${siteAuthConnectionId ?? "*"}:${includeSettled}`;
62
+ const [state, setState] = useState(() => emptyState(key, enabled));
63
+ const [mutationCount, setMutationCount] = useState(0);
64
+ const requestRef = useRef<{ id: number; controller: AbortController | null }>({
65
+ id: 0,
66
+ controller: null,
67
+ });
68
+ const mountedRef = useRef(true);
69
+ const visible = state.key === key ? state : emptyState(key, enabled);
70
+
71
+ const cancelLoad = useCallback(() => {
72
+ requestRef.current.controller?.abort();
73
+ requestRef.current = { id: requestRef.current.id + 1, controller: null };
74
+ }, []);
75
+
76
+ const load = useCallback(
77
+ async (foreground: boolean): Promise<void> => {
78
+ if (!enabled) return;
79
+ const id = requestRef.current.id + 1;
80
+ requestRef.current.controller?.abort();
81
+ const controller = new AbortController();
82
+ requestRef.current = { id, controller };
83
+ setState((current) => ({
84
+ ...(current.key === key ? current : emptyState(key, true)),
85
+ loading: foreground,
86
+ refreshing: !foreground,
87
+ error: foreground || current.key !== key ? null : current.error,
88
+ }));
89
+ try {
90
+ const response = await client.listAuthRuns(workspaceId, {
91
+ ...(browserSessionId ? { browserSessionId } : {}),
92
+ ...(siteAuthConnectionId ? { siteAuthConnectionId } : {}),
93
+ includeSettled,
94
+ signal: controller.signal,
95
+ });
96
+ if (!mountedRef.current || requestRef.current.id !== id) return;
97
+ setState({
98
+ key,
99
+ runs: sortRuns(response.runs),
100
+ loading: false,
101
+ refreshing: false,
102
+ error: null,
103
+ });
104
+ } catch (cause) {
105
+ if (controller.signal.aborted || !mountedRef.current || requestRef.current.id !== id)
106
+ return;
107
+ setState((current) => ({
108
+ ...(current.key === key ? current : emptyState(key, true)),
109
+ loading: false,
110
+ refreshing: false,
111
+ error: asError(cause),
112
+ }));
113
+ } finally {
114
+ if (requestRef.current.id === id) requestRef.current = { id, controller: null };
115
+ }
116
+ },
117
+ [browserSessionId, client, enabled, includeSettled, key, siteAuthConnectionId, workspaceId],
118
+ );
119
+
120
+ const refresh = useCallback(async () => await load(false), [load]);
121
+
122
+ useEffect(() => {
123
+ mountedRef.current = true;
124
+ if (!enabled) {
125
+ cancelLoad();
126
+ setState(emptyState(key, false));
127
+ return;
128
+ }
129
+ void load(true);
130
+ return () => {
131
+ mountedRef.current = false;
132
+ cancelLoad();
133
+ };
134
+ }, [cancelLoad, enabled, key, load]);
135
+
136
+ useInteractionInvalidation({
137
+ workspaceId,
138
+ key,
139
+ enabled,
140
+ event: workspaceInteractionEvent,
141
+ connectionState: workspaceInteractionConnectionState,
142
+ refresh,
143
+ });
144
+
145
+ const mutate = useCallback(async <T>(operation: () => Promise<T>): Promise<T> => {
146
+ setMutationCount((count) => count + 1);
147
+ try {
148
+ return await operation();
149
+ } finally {
150
+ if (mountedRef.current) setMutationCount((count) => Math.max(0, count - 1));
151
+ }
152
+ }, []);
153
+
154
+ const merge = useCallback(
155
+ (run: AuthRun) => {
156
+ setState((current) => {
157
+ if (current.key !== key) return current;
158
+ const runs = current.runs.filter((candidate) => candidate.id !== run.id);
159
+ const matches =
160
+ (!browserSessionId || run.browserSessionId === browserSessionId) &&
161
+ (!siteAuthConnectionId || run.siteAuthConnectionId === siteAuthConnectionId);
162
+ if (matches && (includeSettled || !isSettled(run))) runs.push(run);
163
+ return { ...current, runs: sortRuns(runs), error: null };
164
+ });
165
+ },
166
+ [browserSessionId, includeSettled, key, siteAuthConnectionId],
167
+ );
168
+
169
+ const start = useCallback(
170
+ async (
171
+ scopeBrowserSessionId: string,
172
+ request: Omit<StartAuthRunRequest, "operationId"> & { operationId?: string },
173
+ ): Promise<AuthRunMutationResponse> =>
174
+ await mutate(async () => {
175
+ const response = await client.startBrowserAuthRun(workspaceId, scopeBrowserSessionId, {
176
+ ...request,
177
+ operationId: request.operationId ?? crypto.randomUUID(),
178
+ });
179
+ merge(response.run);
180
+ return response;
181
+ }),
182
+ [client, merge, mutate, workspaceId],
183
+ );
184
+
185
+ const report = useCallback(
186
+ async (
187
+ scopeBrowserSessionId: string,
188
+ authRunId: string,
189
+ request: Omit<ReportAuthRunRequest, "operationId"> & { operationId?: string },
190
+ ): Promise<AuthRunMutationResponse> =>
191
+ await mutate(async () => {
192
+ const response = await client.reportBrowserAuthRun(
193
+ workspaceId,
194
+ scopeBrowserSessionId,
195
+ authRunId,
196
+ { ...request, operationId: request.operationId ?? crypto.randomUUID() },
197
+ );
198
+ merge(response.run);
199
+ return response;
200
+ }),
201
+ [client, merge, mutate, workspaceId],
202
+ );
203
+
204
+ const protectedFill = useCallback(
205
+ async (
206
+ scopeBrowserSessionId: string,
207
+ authRunId: string,
208
+ request: Omit<ProtectedAuthFillRequest, "operationId"> & { operationId?: string },
209
+ ): Promise<ProtectedAuthFillResponse> =>
210
+ await mutate(async () => {
211
+ const response = await client.protectedBrowserAuthFill(
212
+ workspaceId,
213
+ scopeBrowserSessionId,
214
+ authRunId,
215
+ { ...request, operationId: request.operationId ?? crypto.randomUUID() },
216
+ );
217
+ merge(response.run);
218
+ return response;
219
+ }),
220
+ [client, merge, mutate, workspaceId],
221
+ );
222
+
223
+ const verify = useCallback(
224
+ async (
225
+ scopeBrowserSessionId: string,
226
+ authRunId: string,
227
+ request: Omit<VerifyAuthRunRequest, "operationId"> & { operationId?: string },
228
+ ): Promise<AuthRunMutationResponse> =>
229
+ await mutate(async () => {
230
+ const response = await client.verifyBrowserAuthRun(
231
+ workspaceId,
232
+ scopeBrowserSessionId,
233
+ authRunId,
234
+ { ...request, operationId: request.operationId ?? crypto.randomUUID() },
235
+ );
236
+ merge(response.run);
237
+ return response;
238
+ }),
239
+ [client, merge, mutate, workspaceId],
240
+ );
241
+
242
+ return {
243
+ runs: visible.runs,
244
+ loading: visible.loading,
245
+ refreshing: visible.refreshing,
246
+ mutating: mutationCount > 0,
247
+ error: visible.error,
248
+ refresh,
249
+ start,
250
+ report,
251
+ protectedFill,
252
+ verify,
253
+ };
254
+ }
255
+
256
+ function emptyState(key: string, loading: boolean) {
257
+ return {
258
+ key,
259
+ runs: [] as AuthRun[],
260
+ loading,
261
+ refreshing: false,
262
+ error: null as Error | null,
263
+ };
264
+ }
265
+
266
+ function sortRuns(runs: readonly AuthRun[]): AuthRun[] {
267
+ return [...runs].sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
268
+ }
269
+
270
+ function isSettled(run: AuthRun): boolean {
271
+ return run.state === "verified" || run.state === "failed" || run.state === "cancelled";
272
+ }
273
+
274
+ function asError(cause: unknown): Error {
275
+ return cause instanceof Error ? cause : new Error(String(cause));
276
+ }
277
+
278
+ export type AuthRunsClient = EmbeddedBrowserInteractionClientLike;
@@ -0,0 +1,218 @@
1
+ import type { BrowserDownload, BrowserDownloadSaveResponse } from "@opengeni/sdk/interaction";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import type { EmbeddedBrowserInteractionClientLike } from "../client";
4
+ import {
5
+ type EmbeddedBrowserInteractionClientOverride,
6
+ useEmbeddedBrowserInteraction,
7
+ } from "../session-context";
8
+ import { usePageLiveActivity } from "./internal";
9
+
10
+ export type UseBrowserDownloadsOptions = EmbeddedBrowserInteractionClientOverride & {
11
+ browserSessionId: string | null;
12
+ enabled?: boolean | undefined;
13
+ pollIntervalMs?: number | undefined;
14
+ };
15
+
16
+ export type BrowserDownloadSaveOptions = {
17
+ operationId?: string | undefined;
18
+ overwrite?: boolean | undefined;
19
+ };
20
+
21
+ export type UseBrowserDownloadsResult = {
22
+ downloads: BrowserDownload[];
23
+ loading: boolean;
24
+ refreshing: boolean;
25
+ savingDownloadIds: string[];
26
+ error: Error | null;
27
+ refresh: () => Promise<void>;
28
+ saveToWorkspace: (
29
+ downloadId: string,
30
+ destinationPath: string,
31
+ options?: BrowserDownloadSaveOptions,
32
+ ) => Promise<BrowserDownloadSaveResponse>;
33
+ };
34
+
35
+ /** Exact browser-produced files for one BrowserSession. Bytes remain private
36
+ * until `saveToWorkspace` performs the canonical workspace mutation. */
37
+ export function useBrowserDownloads(
38
+ options: UseBrowserDownloadsOptions,
39
+ ): UseBrowserDownloadsResult {
40
+ const { client, workspaceId } = useEmbeddedBrowserInteraction(options);
41
+ const browserSessionId = options.browserSessionId;
42
+ const enabled = (options.enabled ?? true) && browserSessionId !== null;
43
+ const pageLive = usePageLiveActivity();
44
+ const pollIntervalMs = Math.max(750, options.pollIntervalMs ?? 2_000);
45
+ const scope = browserSessionId ?? "none";
46
+ const [state, setState] = useState(() => emptyState(scope, enabled));
47
+ const [saving, setSaving] = useState<Record<string, number>>({});
48
+ const visible = state.scope === scope ? state : emptyState(scope, enabled);
49
+ const requestRef = useRef<{ id: number; controller: AbortController | null }>({
50
+ id: 0,
51
+ controller: null,
52
+ });
53
+ const mountedRef = useRef(true);
54
+ const retryOperationsRef = useRef(new Map<string, string>());
55
+
56
+ const cancelLoad = useCallback(() => {
57
+ requestRef.current.controller?.abort();
58
+ requestRef.current = { id: requestRef.current.id + 1, controller: null };
59
+ }, []);
60
+
61
+ const load = useCallback(
62
+ async (foreground: boolean): Promise<void> => {
63
+ if (!enabled || !browserSessionId) return;
64
+ const id = requestRef.current.id + 1;
65
+ requestRef.current.controller?.abort();
66
+ const controller = new AbortController();
67
+ requestRef.current = { id, controller };
68
+ setState((current) => ({
69
+ ...(current.scope === scope ? current : emptyState(scope, true)),
70
+ loading: foreground,
71
+ refreshing: !foreground,
72
+ error: foreground || current.scope !== scope ? null : current.error,
73
+ }));
74
+ try {
75
+ const response = await client.listBrowserDownloads(workspaceId, browserSessionId, {
76
+ signal: controller.signal,
77
+ });
78
+ if (!mountedRef.current || requestRef.current.id !== id) return;
79
+ setState({
80
+ scope,
81
+ downloads: sortDownloads(response.downloads),
82
+ loading: false,
83
+ refreshing: false,
84
+ error: null,
85
+ });
86
+ } catch (cause) {
87
+ if (controller.signal.aborted || !mountedRef.current || requestRef.current.id !== id)
88
+ return;
89
+ setState((current) => ({
90
+ ...(current.scope === scope ? current : emptyState(scope, true)),
91
+ loading: false,
92
+ refreshing: false,
93
+ error: asError(cause),
94
+ }));
95
+ } finally {
96
+ if (requestRef.current.id === id) requestRef.current = { id, controller: null };
97
+ }
98
+ },
99
+ [browserSessionId, client, enabled, scope, workspaceId],
100
+ );
101
+
102
+ const refresh = useCallback(async () => await load(false), [load]);
103
+
104
+ useEffect(() => {
105
+ retryOperationsRef.current.clear();
106
+ setSaving({});
107
+ }, [scope]);
108
+
109
+ useEffect(() => {
110
+ mountedRef.current = true;
111
+ if (!enabled) {
112
+ cancelLoad();
113
+ setState(emptyState(scope, false));
114
+ return () => {
115
+ mountedRef.current = false;
116
+ cancelLoad();
117
+ };
118
+ }
119
+ void load(true);
120
+ return () => {
121
+ mountedRef.current = false;
122
+ cancelLoad();
123
+ };
124
+ }, [cancelLoad, enabled, load, scope]);
125
+
126
+ useEffect(() => {
127
+ if (!enabled || !pageLive || Object.keys(saving).length > 0) return;
128
+ const timer = setInterval(() => {
129
+ if (!requestRef.current.controller) void refresh();
130
+ }, pollIntervalMs);
131
+ return () => clearInterval(timer);
132
+ }, [enabled, pageLive, pollIntervalMs, refresh, saving]);
133
+
134
+ const saveToWorkspace = useCallback(
135
+ async (
136
+ downloadId: string,
137
+ destinationPath: string,
138
+ saveOptions: BrowserDownloadSaveOptions = {},
139
+ ): Promise<BrowserDownloadSaveResponse> => {
140
+ if (!browserSessionId) throw new Error("No BrowserSession is selected.");
141
+ const overwrite = saveOptions.overwrite ?? false;
142
+ const attemptKey = `${browserSessionId}\u0000${downloadId}\u0000${destinationPath}\u0000${overwrite}`;
143
+ const operationId =
144
+ saveOptions.operationId ??
145
+ retryOperationsRef.current.get(attemptKey) ??
146
+ crypto.randomUUID();
147
+ retryOperationsRef.current.set(attemptKey, operationId);
148
+ setSaving((current) => ({ ...current, [downloadId]: (current[downloadId] ?? 0) + 1 }));
149
+ try {
150
+ const response = await client.saveBrowserDownload(
151
+ workspaceId,
152
+ browserSessionId,
153
+ downloadId,
154
+ { operationId, destinationPath, overwrite },
155
+ );
156
+ retryOperationsRef.current.delete(attemptKey);
157
+ if (mountedRef.current) {
158
+ setState((current) =>
159
+ current.scope === scope
160
+ ? {
161
+ ...current,
162
+ downloads: sortDownloads([
163
+ ...current.downloads.filter((download) => download.id !== response.download.id),
164
+ response.download,
165
+ ]),
166
+ error: null,
167
+ }
168
+ : current,
169
+ );
170
+ }
171
+ return response;
172
+ } finally {
173
+ if (mountedRef.current) {
174
+ setSaving((current) => {
175
+ const count = (current[downloadId] ?? 1) - 1;
176
+ if (count > 0) return { ...current, [downloadId]: count };
177
+ const { [downloadId]: _removed, ...rest } = current;
178
+ return rest;
179
+ });
180
+ }
181
+ }
182
+ },
183
+ [browserSessionId, client, scope, workspaceId],
184
+ );
185
+
186
+ return {
187
+ downloads: visible.downloads,
188
+ loading: visible.loading,
189
+ refreshing: visible.refreshing,
190
+ savingDownloadIds: Object.keys(saving),
191
+ error: visible.error,
192
+ refresh,
193
+ saveToWorkspace,
194
+ };
195
+ }
196
+
197
+ function emptyState(scope: string, loading: boolean) {
198
+ return {
199
+ scope,
200
+ downloads: [] as BrowserDownload[],
201
+ loading,
202
+ refreshing: false,
203
+ error: null as Error | null,
204
+ };
205
+ }
206
+
207
+ function sortDownloads(downloads: readonly BrowserDownload[]): BrowserDownload[] {
208
+ return [...downloads].sort(
209
+ (left, right) =>
210
+ right.startedAt.localeCompare(left.startedAt) || left.id.localeCompare(right.id),
211
+ );
212
+ }
213
+
214
+ function asError(cause: unknown): Error {
215
+ return cause instanceof Error ? cause : new Error(String(cause));
216
+ }
217
+
218
+ export type BrowserDownloadsClient = EmbeddedBrowserInteractionClientLike;