@opengeni/react 7.1.0 → 7.1.2

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 (62) hide show
  1. package/dist/artifacts-document.js +2 -2
  2. package/dist/artifacts-presentation.js +2 -2
  3. package/dist/artifacts-spreadsheet.js +2 -2
  4. package/dist/artifacts.js +4 -4
  5. package/dist/{browser-viewer-MN7MOYQT.js → browser-viewer-TZ6T76E3.js} +2 -2
  6. package/dist/{chunk-KCGXPP5V.js → chunk-6P7T4RPE.js} +7 -3
  7. package/dist/chunk-6P7T4RPE.js.map +1 -0
  8. package/dist/{chunk-SHRCATV6.js → chunk-7J4DUR3E.js} +12 -5
  9. package/dist/chunk-7J4DUR3E.js.map +1 -0
  10. package/dist/{chunk-UQS7BGNQ.js → chunk-CP6IFD2Z.js} +79 -5
  11. package/dist/chunk-CP6IFD2Z.js.map +1 -0
  12. package/dist/{chunk-VR4Z73EU.js → chunk-G2SCXNXW.js} +9 -2
  13. package/dist/chunk-G2SCXNXW.js.map +1 -0
  14. package/dist/{chunk-5UONOQ3Y.js → chunk-K3MQZO7R.js} +150 -67
  15. package/dist/chunk-K3MQZO7R.js.map +1 -0
  16. package/dist/{chunk-OWHPKAOV.js → chunk-L6MYP3QU.js} +4 -2
  17. package/dist/{chunk-OWHPKAOV.js.map → chunk-L6MYP3QU.js.map} +1 -1
  18. package/dist/{chunk-ZC2DK5SC.js → chunk-VHEY4CB3.js} +84 -5
  19. package/dist/chunk-VHEY4CB3.js.map +1 -0
  20. package/dist/{chunk-XMXRI5JK.js → chunk-ZZ4744OZ.js} +128 -71
  21. package/dist/chunk-ZZ4744OZ.js.map +1 -0
  22. package/dist/components/artifacts/artifact-surface.d.ts +3 -1
  23. package/dist/components/artifacts/document-editor.d.ts +4 -2
  24. package/dist/components/artifacts/editable-document.d.ts +1 -1
  25. package/dist/components/artifacts/editable-presentation.d.ts +1 -1
  26. package/dist/components/artifacts/editable-spreadsheet.d.ts +2 -1
  27. package/dist/components/artifacts/presentation-editor.d.ts +4 -2
  28. package/dist/components/browser-viewer.d.ts +4 -1
  29. package/dist/components/markdown.d.ts +2 -0
  30. package/dist/components/session-capability-frame.d.ts +5 -1
  31. package/dist/connect.js +2 -2
  32. package/dist/hooks/use-browser-session.d.ts +3 -0
  33. package/dist/index.js +5 -4
  34. package/dist/index.js.map +1 -1
  35. package/dist/interaction.js +1 -1
  36. package/dist/session-ui.js +2 -2
  37. package/package.json +2 -2
  38. package/src/components/artifacts/artifact-surface.tsx +39 -19
  39. package/src/components/artifacts/document-editor.tsx +77 -9
  40. package/src/components/artifacts/editable-document.tsx +3 -0
  41. package/src/components/artifacts/editable-presentation.tsx +3 -0
  42. package/src/components/artifacts/editable-spreadsheet.tsx +3 -0
  43. package/src/components/artifacts/presentation-editor.tsx +6 -0
  44. package/src/components/browser-viewer.tsx +10 -0
  45. package/src/components/markdown.tsx +9 -2
  46. package/src/components/sandbox-workspace.tsx +1 -0
  47. package/src/components/session-capability-frame.tsx +11 -3
  48. package/src/components/session-mcp-capability-card.tsx +174 -70
  49. package/src/hooks/use-browser-session.ts +107 -3
  50. package/src/timeline/parsers.ts +2 -2
  51. package/src/timeline/tool-renderers.tsx +105 -16
  52. package/src/timeline/turn-summary.tsx +10 -2
  53. package/styles/compiled.css +6 -0
  54. package/styles/session-capability.css +2 -0
  55. package/dist/chunk-5UONOQ3Y.js.map +0 -1
  56. package/dist/chunk-KCGXPP5V.js.map +0 -1
  57. package/dist/chunk-SHRCATV6.js.map +0 -1
  58. package/dist/chunk-UQS7BGNQ.js.map +0 -1
  59. package/dist/chunk-VR4Z73EU.js.map +0 -1
  60. package/dist/chunk-XMXRI5JK.js.map +0 -1
  61. package/dist/chunk-ZC2DK5SC.js.map +0 -1
  62. /package/dist/{browser-viewer-MN7MOYQT.js.map → browser-viewer-TZ6T76E3.js.map} +0 -0
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  authorizeConnectAttempt,
3
+ ConnectPopupClosedError,
3
4
  ConnectController,
4
5
  createBrowserConnectNavigation,
5
6
  reserveBrowserConnectNavigation,
@@ -77,25 +78,32 @@ function ScopedCard({
77
78
  const [complete, setComplete] = useState(false);
78
79
  const [connected, setConnected] = useState(false);
79
80
  const [waiting, setWaiting] = useState(false);
81
+ const [reconciling, setReconciling] = useState(false);
80
82
  const [busy, setBusy] = useState(false);
81
83
  const [error, setError] = useState<string | null>(null);
84
+ const [notice, setNotice] = useState<string | null>(null);
85
+ const [retryFresh, setRetryFresh] = useState(false);
82
86
  const [ownership, setOwnership] = useState<ConnectOwnership>("workspace");
83
87
  const lifetime = useRef<AbortController | null>(null);
84
- const operation = useRef(false);
88
+ const authorization = useRef<AbortController | null>(null);
89
+ const operation = useRef<AbortController | null>(null);
90
+ const reservedPopup = useRef<(() => void) | null>(null);
85
91
  const startKey = useRef(crypto.randomUUID());
86
92
  const advanceKey = useRef(crypto.randomUUID());
93
+ const startInput = useRef<string | null>(null);
87
94
  const storageKey = sessionId
88
95
  ? `opengeni:session-connect:${workspaceId}:${sessionId}:${capabilityId}`
89
96
  : `opengeni:workspace-connect:${workspaceId}:${capabilityId}`;
90
97
  const current = () => !!lifetime.current && !lifetime.current.signal.aborted;
91
98
 
92
- async function load() {
99
+ async function load(active: () => boolean = current) {
93
100
  const invocation = lifetime.current;
94
101
  const [catalog, session] = await Promise.all([
95
102
  client.listCapabilities(workspaceId),
96
103
  sessionId ? client.getSession(workspaceId, sessionId) : Promise.resolve(null),
97
104
  ]);
98
- if (!invocation || invocation.signal.aborted || lifetime.current !== invocation) return null;
105
+ if (!invocation || invocation.signal.aborted || lifetime.current !== invocation || !active())
106
+ return null;
99
107
  const resolved = catalog.items.find((entry) => entry.id === capabilityId);
100
108
  if (!resolved || resolved.kind !== "mcp" || resolved.authKind !== "oauth2")
101
109
  throw new Error(
@@ -112,11 +120,11 @@ function ScopedCard({
112
120
  resolved.connectionRef.subjectScope === "subject"
113
121
  ? await client.listOwnConnectionAccounts(workspaceId)
114
122
  : await client.listConnections(workspaceId);
115
- if (invocation.signal.aborted || lifetime.current !== invocation) return null;
123
+ if (invocation.signal.aborted || lifetime.current !== invocation || !active()) return null;
116
124
  const matches = matchingActiveMcpConnections(resolved, connections);
117
125
  accountReady = matches.length === 1;
118
126
  }
119
- if (invocation.signal.aborted || lifetime.current !== invocation) return null;
127
+ if (invocation.signal.aborted || lifetime.current !== invocation || !active()) return null;
120
128
  const selected =
121
129
  session?.toolPolicy.mode === "workspace_default"
122
130
  ? session.effectiveToolPolicy?.selectedIds
@@ -185,23 +193,32 @@ function ScopedCard({
185
193
  };
186
194
  }, [client, item?.logoAssetPath]);
187
195
 
188
- async function run(action: () => Promise<void>) {
196
+ async function run(action: (active: () => boolean) => Promise<void>) {
189
197
  if (operation.current || !current()) return;
190
- operation.current = true;
198
+ const pending = new AbortController();
199
+ operation.current = pending;
200
+ const active = () => current() && operation.current === pending && !pending.signal.aborted;
191
201
  setBusy(true);
192
202
  setError(null);
203
+ setNotice(null);
204
+ setRetryFresh(false);
193
205
  try {
194
- await action();
206
+ await action(active);
195
207
  } catch (failure) {
196
- if (current())
197
- setError(
198
- failure instanceof Error
199
- ? failure.message
200
- : "Connection setup could not finish. Try again.",
201
- );
208
+ if (active()) {
209
+ if (failure instanceof ConnectPopupClosedError) setNotice(failure.message);
210
+ else
211
+ setError(
212
+ failure instanceof Error
213
+ ? failure.message
214
+ : "Connection setup could not finish. Try again.",
215
+ );
216
+ }
202
217
  } finally {
203
- operation.current = false;
204
- if (current()) setBusy(false);
218
+ if (operation.current === pending) {
219
+ operation.current = null;
220
+ if (current()) setBusy(false);
221
+ }
205
222
  }
206
223
  }
207
224
 
@@ -215,15 +232,15 @@ function ScopedCard({
215
232
  }
216
233
  }
217
234
 
218
- async function reconcile(attempt: ConnectAttempt) {
219
- if (!current()) return;
235
+ async function reconcile(attempt: ConnectAttempt, active: () => boolean = current) {
236
+ if (!active()) return;
220
237
  if (attempt.state !== "complete" || !attempt.account || !attempt.credentialsCommitted) return;
221
- const resolved = await load();
222
- if (!resolved || !current()) return;
238
+ const resolved = await load(active);
239
+ if (!resolved || !active()) return;
223
240
  const connection = (await client.listConnections(workspaceId)).find(
224
241
  (entry) => entry.id === attempt.account!.id,
225
242
  );
226
- if (!current()) return;
243
+ if (!active()) return;
227
244
  if (
228
245
  !connection ||
229
246
  connection.status !== "active" ||
@@ -255,11 +272,12 @@ function ScopedCard({
255
272
  connectionId: connection.id,
256
273
  },
257
274
  });
258
- if (!current()) return;
259
- const enabled = await load();
260
- if (enabled && current()) {
261
- await finishConnection(enabled);
275
+ if (!active()) return;
276
+ const enabled = await load(active);
277
+ if (enabled && active()) {
278
+ await finishConnection(enabled, active);
262
279
  }
280
+ if (!active()) return;
263
281
  try {
264
282
  sessionStorage.removeItem(storageKey);
265
283
  } catch {
@@ -269,14 +287,14 @@ function ScopedCard({
269
287
 
270
288
  async function open() {
271
289
  setExpanded(true);
272
- await run(async () => {
273
- await load();
274
- if (!current()) return;
275
- await recoverSavedAttempt();
290
+ await run(async (active) => {
291
+ await load(active);
292
+ if (!active()) return;
293
+ await recoverSavedAttempt(active);
276
294
  });
277
295
  }
278
296
 
279
- async function recoverSavedAttempt() {
297
+ async function recoverSavedAttempt(active: () => boolean = current) {
280
298
  let saved: string | null = null;
281
299
  try {
282
300
  saved = sessionStorage.getItem(storageKey);
@@ -285,20 +303,31 @@ function ScopedCard({
285
303
  }
286
304
  if (saved) {
287
305
  const attempt = await controller.recover(saved);
288
- await reconcile(attempt);
306
+ if (!active()) return;
307
+ if (["failed", "expired", "cancelled"].includes(attempt.state)) setRetryFresh(true);
308
+ if (attempt.state === "complete") setReconciling(true);
309
+ try {
310
+ await reconcile(attempt, active);
311
+ } finally {
312
+ if (active()) setReconciling(false);
313
+ }
289
314
  }
290
315
  }
291
316
 
292
- async function begin() {
293
- await run(async () => {
294
- if (view.attempt && ["failed", "expired", "cancelled"].includes(view.attempt.state)) {
317
+ async function begin(fresh = false) {
318
+ await run(async (active) => {
319
+ if (
320
+ fresh ||
321
+ (view.attempt && ["failed", "expired", "cancelled"].includes(view.attempt.state))
322
+ ) {
295
323
  startKey.current = crypto.randomUUID();
296
324
  advanceKey.current = crypto.randomUUID();
297
325
  }
298
326
  const reserved = reserveBrowserConnectNavigation(window);
327
+ reservedPopup.current = reserved.close;
299
328
  try {
300
- const resolved = await load();
301
- if (!resolved || !current()) return;
329
+ const resolved = await load(active);
330
+ if (!resolved || !active()) return;
302
331
  if (
303
332
  resolved.connectionRef &&
304
333
  ownership !==
@@ -322,13 +351,22 @@ function ScopedCard({
322
351
  entry.kind === "oauth2" &&
323
352
  entry.metadata.mcpUrl === mcpUrl,
324
353
  );
325
- if (!current()) return;
354
+ if (!active()) return;
326
355
  if (connections.length > 1)
327
356
  throw new Error(
328
357
  "More than one personal account matches. Choose the account in connection settings before reconnecting.",
329
358
  );
330
359
  reconnectAccountId = connections[0]?.id;
331
360
  }
361
+ // A closed request can still have reached the server. Keep its key for
362
+ // an exact replay, but never reuse it when ownership or another input
363
+ // changes while the user reopens setup.
364
+ const input = JSON.stringify({ ownership, returnUrl, reconnectAccountId, mcpUrl });
365
+ if (startInput.current !== null && startInput.current !== input) {
366
+ startKey.current = crypto.randomUUID();
367
+ advanceKey.current = crypto.randomUUID();
368
+ }
369
+ startInput.current = input;
332
370
  let attempt = await controller.begin({
333
371
  providerId: "mcp-oauth",
334
372
  ownership,
@@ -336,49 +374,79 @@ function ScopedCard({
336
374
  idempotencyKey: startKey.current,
337
375
  ...(reconnectAccountId ? { reconnectAccountId } : {}),
338
376
  });
339
- if (!current()) return;
377
+ if (!active()) return;
340
378
  remember(attempt.id);
341
379
  if (attempt.state === "credential_input")
342
380
  attempt = await controller.advance(
343
381
  { type: "credentials", values: { mcpUrl } },
344
382
  advanceKey.current,
345
383
  );
384
+ if (!active()) return;
346
385
  if (attempt.nextAction.type === "authorize")
347
- await performAuthorization(attempt, reserved.navigation);
348
- else await reconcile(attempt);
386
+ await performAuthorization(attempt, reserved.navigation, active);
387
+ else await reconcile(attempt, active);
349
388
  } finally {
389
+ if (reservedPopup.current === reserved.close) reservedPopup.current = null;
350
390
  reserved.close();
351
391
  }
352
392
  });
353
393
  }
354
394
 
355
- async function performAuthorization(attempt: ConnectAttempt, navigation: ConnectNavigation) {
395
+ async function performAuthorization(
396
+ attempt: ConnectAttempt,
397
+ navigation: ConnectNavigation,
398
+ active: () => boolean,
399
+ ) {
400
+ if (!active()) return;
401
+ const pending = new AbortController();
402
+ authorization.current = pending;
403
+ const abortOnUnmount = () => pending.abort(lifetime.current?.signal.reason);
404
+ lifetime.current!.signal.addEventListener("abort", abortOnUnmount, { once: true });
356
405
  setWaiting(true);
357
406
  try {
358
407
  const result = await authorizeConnectAttempt(controller.transport, attempt, navigation, {
359
408
  mode: "popup",
360
- signal: lifetime.current!.signal,
409
+ signal: pending.signal,
361
410
  });
362
- if (!current() || !result) return;
363
- await controller.refresh();
364
- await reconcile(result);
365
- if (result.state !== "complete")
411
+ if (!active() || !result) return;
412
+ setWaiting(false);
413
+ setReconciling(true);
414
+ await reconcile(result, active);
415
+ if (!active()) return;
416
+ if (result.state === "cancelled") {
417
+ setNotice("Sign-in was cancelled. You can try connecting again.");
418
+ setRetryFresh(true);
419
+ return;
420
+ }
421
+ if (result.state !== "complete") {
422
+ if (["failed", "expired"].includes(result.state)) setRetryFresh(true);
366
423
  throw new Error("Sign-in did not finish. You can try connecting again.");
424
+ }
367
425
  } finally {
368
- if (current()) setWaiting(false);
426
+ lifetime.current?.signal.removeEventListener("abort", abortOnUnmount);
427
+ if (authorization.current === pending) authorization.current = null;
428
+ if (active()) {
429
+ setReconciling(false);
430
+ setWaiting(false);
431
+ }
369
432
  }
370
433
  }
371
434
 
372
435
  function authorize(attempt: ConnectAttempt) {
373
436
  // run invokes action synchronously, retaining the browser click gesture.
374
- return run(() => performAuthorization(attempt, createBrowserConnectNavigation(window)));
437
+ return run((active) =>
438
+ performAuthorization(attempt, createBrowserConnectNavigation(window), active),
439
+ );
375
440
  }
376
441
 
377
- async function finishConnection(capability: CapabilityCatalogItem) {
442
+ async function finishConnection(
443
+ capability: CapabilityCatalogItem,
444
+ active: () => boolean = current,
445
+ ) {
378
446
  if (!sessionId) {
379
447
  // Connection management does not select session tools.
380
448
  await onConfigured?.();
381
- if (!current()) return;
449
+ if (!active()) return;
382
450
  setComplete(true);
383
451
  setExpanded(false);
384
452
  onClose?.();
@@ -388,31 +456,47 @@ function ScopedCard({
388
456
  const selected = (await client.listConnections(workspaceId)).find(
389
457
  (entry) => entry.id === capability.connectionRef?.connectionId,
390
458
  );
391
- if (!current()) return;
459
+ if (!active()) return;
392
460
  if (!selected || selected.status !== "active")
393
461
  throw new Error("This account needs reconnection before it can be used here.");
394
462
  }
395
463
  if (capability.connectionRef?.subjectScope === "subject") {
396
464
  const accounts = await client.listOwnConnectionAccounts(workspaceId);
397
- if (!current()) return;
465
+ if (!active()) return;
398
466
  if (matchingActiveMcpConnections(capability, accounts).length === 0)
399
467
  throw new Error("Your account needs reconnection before it can be used here.");
400
468
  }
401
- if (!current()) return;
402
- await attachSessionCapability(client, workspaceId, sessionId, capability, current);
403
- if (!current()) return;
469
+ if (!active()) return;
470
+ await attachSessionCapability(client, workspaceId, sessionId, capability, active);
471
+ if (!active()) return;
404
472
  await onConfigured?.();
405
- if (!current()) return;
473
+ if (!active()) return;
406
474
  setComplete(true);
407
475
  setExpanded(false);
408
476
  }
409
477
 
410
478
  async function useHere() {
411
- await run(async () => {
412
- if (item) await finishConnection(item);
479
+ await run(async (active) => {
480
+ if (item) await finishConnection(item, active);
413
481
  });
414
482
  }
415
483
 
484
+ function close() {
485
+ const pending = operation.current;
486
+ if (pending) {
487
+ operation.current = null;
488
+ pending.abort(new ConnectPopupClosedError());
489
+ authorization.current?.abort(new ConnectPopupClosedError());
490
+ authorization.current = null;
491
+ reservedPopup.current?.();
492
+ setBusy(false);
493
+ setWaiting(false);
494
+ setReconciling(false);
495
+ }
496
+ setExpanded(false);
497
+ onClose?.();
498
+ }
499
+
416
500
  return (
417
501
  <SessionCapabilityFrame
418
502
  name={item?.name ?? name}
@@ -436,15 +520,16 @@ function ScopedCard({
436
520
  : "Review access before signing in. You'll return here after authorization."
437
521
  }
438
522
  onOpen={() => void open()}
439
- onClose={() => {
440
- setExpanded(false);
441
- onClose?.();
442
- }}
443
- busy={busy || view.busy}
523
+ onClose={close}
444
524
  dialogOnly={dialogOnly}
445
525
  >
446
526
  <div className="og-session-capability-setup">
447
527
  {error ? <p role="alert">{error}</p> : null}
528
+ {notice ? (
529
+ <p role="status" className="og-session-capability-notice">
530
+ {notice}
531
+ </p>
532
+ ) : null}
448
533
  {!item ? (
449
534
  <>
450
535
  <p role="status">
@@ -458,11 +543,24 @@ function ScopedCard({
458
543
  <>
459
544
  <p>{item.description || rationale}</p>
460
545
  {busy ? (
461
- <p role="status" className="og-session-capability-progress">
462
- {waiting
463
- ? `Finish signing in with ${item.name} in the opened window. This will close automatically when you’re connected.`
464
- : "Preparing your connection…"}
465
- </p>
546
+ <>
547
+ <p role="status" className="og-session-capability-progress">
548
+ {waiting
549
+ ? `Finish signing in with ${item.name} in the opened window. This will close automatically when you’re connected.`
550
+ : reconciling
551
+ ? "Finishing your connection…"
552
+ : "Preparing your connection…"}
553
+ </p>
554
+ {waiting ? (
555
+ <button
556
+ type="button"
557
+ className="og-session-capability-stop"
558
+ onClick={() => authorization.current?.abort(new ConnectPopupClosedError())}
559
+ >
560
+ Stop waiting
561
+ </button>
562
+ ) : null}
563
+ </>
466
564
  ) : !connected ? (
467
565
  <>
468
566
  {!item.connectionRef ? (
@@ -498,11 +596,17 @@ function ScopedCard({
498
596
  <button
499
597
  className="og-session-capability-primary"
500
598
  onClick={() => {
501
- if (view.attempt?.nextAction.type === "authorize") void authorize(view.attempt);
599
+ if (notice || retryFresh) void begin(true);
600
+ else if (
601
+ view.attempt?.nextAction.type === "authorize" &&
602
+ view.attempt.ownership === ownership
603
+ )
604
+ void authorize(view.attempt);
502
605
  else void begin();
503
606
  }}
504
607
  >
505
- {error && view.attempt?.nextAction.type === "authorize"
608
+ {retryFresh ||
609
+ ((error || notice) && view.attempt?.nextAction.type === "authorize")
506
610
  ? "Try signing in again"
507
611
  : `Continue to ${item.name}`}
508
612
  </button>
@@ -23,6 +23,9 @@ export type UseBrowserSessionOptions = EmbeddedBrowserInteractionClientOverride
23
23
  browserSessionId: string | null;
24
24
  enabled?: boolean | undefined;
25
25
  pollIntervalMs?: number | undefined;
26
+ /** Keep session/tab inventory current without repeatedly fetching the full
27
+ * accessibility tree while a live frame is available or the viewer is hidden. */
28
+ semanticObservationEnabled?: boolean | undefined;
26
29
  };
27
30
 
28
31
  export type UseBrowserSessionResult = {
@@ -58,6 +61,13 @@ export function useBrowserSession(options: UseBrowserSessionOptions): UseBrowser
58
61
  const { client, workspaceId } = useEmbeddedBrowserInteraction(options);
59
62
  const browserSessionId = options.browserSessionId;
60
63
  const enabled = (options.enabled ?? true) && browserSessionId !== null;
64
+ const semanticObservationEnabled = options.semanticObservationEnabled ?? true;
65
+ const semanticObservationEnabledRef = useRef(semanticObservationEnabled);
66
+ semanticObservationEnabledRef.current = semanticObservationEnabled;
67
+ const previousSemanticObservationRef = useRef({
68
+ browserSessionId,
69
+ enabled: semanticObservationEnabled,
70
+ });
61
71
  const pageLive = usePageLiveActivity();
62
72
  const pollIntervalMs = Math.max(750, options.pollIntervalMs ?? 2_000);
63
73
  const [state, setState] = useState<{
@@ -75,10 +85,16 @@ export function useBrowserSession(options: UseBrowserSessionOptions): UseBrowser
75
85
  const refreshBlocked = isNonRetryableInteractionError(visible.error);
76
86
  const selectedTargetIdRef = useRef<string | null>(visible.selectedTargetId);
77
87
  selectedTargetIdRef.current = visible.selectedTargetId;
88
+ const selectedTargetRef = useRef<BrowserTarget | null>(
89
+ visible.targets.find((target) => target.id === visible.selectedTargetId) ?? null,
90
+ );
91
+ selectedTargetRef.current =
92
+ visible.targets.find((target) => target.id === visible.selectedTargetId) ?? null;
78
93
  const observationRef = useRef<{
79
94
  browserSessionId: string | null;
80
95
  observation: BrowserObservation | null;
81
96
  }>({ browserSessionId, observation: visible.observation });
97
+ const lastObservationAtRef = useRef(0);
82
98
  if (observationRef.current.browserSessionId !== browserSessionId) {
83
99
  observationRef.current = {
84
100
  browserSessionId,
@@ -124,7 +140,15 @@ export function useBrowserSession(options: UseBrowserSessionOptions): UseBrowser
124
140
  if (!mountedRef.current || requestRef.current.id !== id) return;
125
141
  const selected = chooseTarget(targetResponse.targets, selectedTargetIdRef.current);
126
142
  selectedTargetIdRef.current = selected?.id ?? null;
127
- observationRef.current = { browserSessionId, observation: null };
143
+ const previousObservation =
144
+ observationRef.current.browserSessionId === browserSessionId
145
+ ? observationRef.current.observation
146
+ : null;
147
+ const retainedObservation =
148
+ selected && previousObservation && sameObservationTarget(previousObservation, selected)
149
+ ? previousObservation
150
+ : null;
151
+ observationRef.current = { browserSessionId, observation: retainedObservation };
128
152
  setState((current) =>
129
153
  current.browserSessionId === browserSessionId
130
154
  ? {
@@ -132,13 +156,13 @@ export function useBrowserSession(options: UseBrowserSessionOptions): UseBrowser
132
156
  session,
133
157
  targets: targetResponse.targets,
134
158
  selectedTargetId: selected?.id ?? null,
135
- observation: null,
159
+ observation: retainedObservation,
136
160
  loading: false,
137
161
  error: null,
138
162
  }
139
163
  : current,
140
164
  );
141
- if (!selected) return;
165
+ if (!selected || !semanticObservationEnabledRef.current) return;
142
166
  try {
143
167
  const observation = await client.observeBrowserTarget(
144
168
  workspaceId,
@@ -148,6 +172,7 @@ export function useBrowserSession(options: UseBrowserSessionOptions): UseBrowser
148
172
  );
149
173
  if (!mountedRef.current || requestRef.current.id !== id) return;
150
174
  observationRef.current = { browserSessionId, observation };
175
+ lastObservationAtRef.current = Date.now();
151
176
  setState((current) =>
152
177
  current.browserSessionId === browserSessionId &&
153
178
  current.selectedTargetId === observation.target.id
@@ -180,6 +205,37 @@ export function useBrowserSession(options: UseBrowserSessionOptions): UseBrowser
180
205
  }
181
206
  }, [browserSessionId, client, enabled, workspaceId]);
182
207
 
208
+ const refreshSemantic = useCallback(async (): Promise<void> => {
209
+ if (!enabled || !browserSessionId) return;
210
+ const target = selectedTargetRef.current;
211
+ if (!target) return;
212
+ const id = requestRef.current.id + 1;
213
+ requestRef.current.controller?.abort();
214
+ const controller = new AbortController();
215
+ requestRef.current = { id, controller };
216
+ try {
217
+ const observation = await client.observeBrowserTarget(
218
+ workspaceId,
219
+ browserSessionId,
220
+ target.id,
221
+ { signal: controller.signal },
222
+ );
223
+ if (!mountedRef.current || requestRef.current.id !== id) return;
224
+ if (!sameObservationTarget(observation, selectedTargetRef.current)) return;
225
+ observationRef.current = { browserSessionId, observation };
226
+ lastObservationAtRef.current = Date.now();
227
+ setState((current) =>
228
+ current.browserSessionId === browserSessionId && current.selectedTargetId === target.id
229
+ ? { ...current, observation }
230
+ : current,
231
+ );
232
+ } catch {
233
+ // A failed semantic fallback cannot disable an otherwise live browser.
234
+ } finally {
235
+ if (requestRef.current.id === id) requestRef.current = { id, controller: null };
236
+ }
237
+ }, [browserSessionId, client, enabled, workspaceId]);
238
+
183
239
  useEffect(() => {
184
240
  mountedRef.current = true;
185
241
  if (!enabled) {
@@ -199,6 +255,37 @@ export function useBrowserSession(options: UseBrowserSessionOptions): UseBrowser
199
255
  };
200
256
  }, [browserSessionId, enabled, invalidateRefresh, refresh]);
201
257
 
258
+ useEffect(() => {
259
+ const previous = previousSemanticObservationRef.current;
260
+ previousSemanticObservationRef.current = {
261
+ browserSessionId,
262
+ enabled: semanticObservationEnabled,
263
+ };
264
+ if (
265
+ enabled &&
266
+ semanticObservationEnabled &&
267
+ previous.browserSessionId === browserSessionId &&
268
+ !previous.enabled
269
+ ) {
270
+ // The media stream disappeared or the user returned to the Browser tab.
271
+ // A just-returned action/tab observation is already a fresh fallback.
272
+ // Otherwise drop the old tree/fence until a new read settles.
273
+ const currentObservation = observationRef.current.observation;
274
+ if (
275
+ currentObservation &&
276
+ sameObservationTarget(currentObservation, selectedTargetRef.current) &&
277
+ Date.now() - lastObservationAtRef.current < pollIntervalMs
278
+ ) {
279
+ return;
280
+ }
281
+ observationRef.current = { browserSessionId, observation: null };
282
+ setState((current) =>
283
+ current.browserSessionId === browserSessionId ? { ...current, observation: null } : current,
284
+ );
285
+ void refreshSemantic();
286
+ }
287
+ }, [browserSessionId, enabled, pollIntervalMs, refreshSemantic, semanticObservationEnabled]);
288
+
202
289
  useEffect(() => {
203
290
  if (!enabled || !pageLive || visible.mutating || refreshBlocked) return;
204
291
  const timer = setInterval(() => {
@@ -301,6 +388,7 @@ export function useBrowserSession(options: UseBrowserSessionOptions): UseBrowser
301
388
  }
302
389
  selectedTargetIdRef.current = target.id;
303
390
  observationRef.current = { browserSessionId, observation };
391
+ lastObservationAtRef.current = Date.now();
304
392
  setState((current) =>
305
393
  current.browserSessionId === browserSessionId
306
394
  ? {
@@ -332,6 +420,7 @@ export function useBrowserSession(options: UseBrowserSessionOptions): UseBrowser
332
420
  const target = observation.target;
333
421
  selectedTargetIdRef.current = target.id;
334
422
  observationRef.current = { browserSessionId, observation };
423
+ lastObservationAtRef.current = Date.now();
335
424
  setState((current) =>
336
425
  current.browserSessionId === browserSessionId
337
426
  ? {
@@ -365,6 +454,7 @@ export function useBrowserSession(options: UseBrowserSessionOptions): UseBrowser
365
454
  : null;
366
455
  selectedTargetIdRef.current = selected?.id ?? null;
367
456
  observationRef.current = { browserSessionId, observation };
457
+ if (observation) lastObservationAtRef.current = Date.now();
368
458
  setState((current) =>
369
459
  current.browserSessionId === browserSessionId
370
460
  ? {
@@ -424,6 +514,7 @@ export function useBrowserSession(options: UseBrowserSessionOptions): UseBrowser
424
514
  browserSessionId,
425
515
  observation: receipt.observation,
426
516
  };
517
+ lastObservationAtRef.current = Date.now();
427
518
  setState((current) =>
428
519
  current.browserSessionId === browserSessionId
429
520
  ? {
@@ -540,6 +631,19 @@ function replaceTarget(targets: readonly BrowserTarget[], target: BrowserTarget)
540
631
  return next.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
541
632
  }
542
633
 
634
+ function sameObservationTarget(
635
+ observation: BrowserObservation,
636
+ target: BrowserTarget | null,
637
+ ): boolean {
638
+ return (
639
+ target !== null &&
640
+ observation.target.id === target.id &&
641
+ observation.target.controllerGeneration === target.controllerGeneration &&
642
+ observation.target.targetGeneration === target.targetGeneration &&
643
+ observation.target.documentGeneration === target.documentGeneration
644
+ );
645
+ }
646
+
543
647
  function isMissingBrowserTarget(error: unknown): boolean {
544
648
  return error instanceof OpenGeniApiError && error.code === "target_not_found";
545
649
  }
@@ -566,8 +566,8 @@ export function retainedScreenshotMetadata(out: unknown): RetainedArtifactMetada
566
566
  if (!value.available) {
567
567
  return typeof value.reason === "string" ? (value as unknown as RetainedArtifactMetadata) : null;
568
568
  }
569
- return value.kind === "computer_screenshot" &&
570
- value.contentType === "image/png" &&
569
+ return (value.kind === "computer_screenshot" || value.kind === "browser_screenshot") &&
570
+ ["image/png", "image/jpeg", "image/webp"].includes(String(value.contentType)) &&
571
571
  typeof value.originalBytes === "number" &&
572
572
  typeof value.sha256 === "string" &&
573
573
  value.dimensions !== null &&