@opengeni/react 1.0.1 → 2.3.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-5YK2ZDIG.js +116 -0
  8. package/dist/chunk-5YK2ZDIG.js.map +1 -0
  9. package/dist/chunk-H5YGYNXS.js +329 -0
  10. package/dist/chunk-H5YGYNXS.js.map +1 -0
  11. package/dist/{chunk-7WCLAEFV.js → chunk-ICA6R6TW.js} +2 -2
  12. package/dist/{chunk-7WCLAEFV.js.map → chunk-ICA6R6TW.js.map} +1 -1
  13. package/dist/{chunk-IGC6A2ZI.js → chunk-JOAVA2TW.js} +589 -217
  14. package/dist/chunk-JOAVA2TW.js.map +1 -0
  15. package/dist/{chunk-Y7OEDUJ5.js → chunk-LS4POUTY.js} +1057 -426
  16. package/dist/chunk-LS4POUTY.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-6KLX2RH5.js → chunk-RSXJN73N.js} +365 -21
  24. package/dist/chunk-RSXJN73N.js.map +1 -0
  25. package/dist/{chunk-B6WPZSTT.js → chunk-SE4W4WQ3.js} +78 -16
  26. package/dist/chunk-SE4W4WQ3.js.map +1 -0
  27. package/dist/{chunk-MKPIAGVL.js → chunk-W6NFJXSA.js} +8 -19
  28. package/dist/chunk-W6NFJXSA.js.map +1 -0
  29. package/dist/{chunk-XBZWOM2K.js → chunk-X4A66EWZ.js} +8 -3
  30. package/dist/chunk-X4A66EWZ.js.map +1 -0
  31. package/dist/chunk-X4JAFD5R.js +3907 -0
  32. package/dist/chunk-X4JAFD5R.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 +50 -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 +103 -59
  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 +215 -18
  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 +529 -25
  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 +77 -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
@@ -233,12 +233,14 @@ export function useSandboxGit(
233
233
  // survive renders, so they must be fenced/reset when that identity changes.
234
234
  const refreshGenerationRef = useRef(0);
235
235
  const refreshAbortRef = useRef<AbortController | null>(null);
236
+ const refreshInFlightRef = useRef<Promise<void> | null>(null);
237
+ const refreshPendingRef = useRef(false);
238
+ const refreshWorkerEpochRef = useRef(0);
236
239
  const lastChangeRef = useRef(0);
237
- const commandRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
240
+ const eventRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
238
241
 
239
- const refresh = useCallback(async () => {
242
+ const performRefresh = useCallback(async () => {
240
243
  if (!sessionId) return;
241
- refreshAbortRef.current?.abort();
242
244
  const refreshAbort = new AbortController();
243
245
  refreshAbortRef.current = refreshAbort;
244
246
  const generation = (refreshGenerationRef.current += 1);
@@ -377,6 +379,32 @@ export function useSandboxGit(
377
379
  comparison,
378
380
  ]);
379
381
 
382
+ // Provider reads cannot be undispatched after they reach a remote sandbox.
383
+ // Keep exactly one refresh in flight and collapse any burst of invalidations
384
+ // into one trailing read. Aborting and replacing every tool-output refresh
385
+ // used to leave several Modal reads contending for the same provider lock.
386
+ const performRefreshRef = useRef(performRefresh);
387
+ performRefreshRef.current = performRefresh;
388
+ const refresh = useCallback(async () => {
389
+ refreshPendingRef.current = true;
390
+ const existing = refreshInFlightRef.current;
391
+ if (existing) return await existing;
392
+
393
+ const workerEpoch = refreshWorkerEpochRef.current;
394
+ const run = (async () => {
395
+ while (refreshPendingRef.current && refreshWorkerEpochRef.current === workerEpoch) {
396
+ refreshPendingRef.current = false;
397
+ await performRefreshRef.current();
398
+ }
399
+ })();
400
+ refreshInFlightRef.current = run;
401
+ try {
402
+ await run;
403
+ } finally {
404
+ if (refreshInFlightRef.current === run) refreshInFlightRef.current = null;
405
+ }
406
+ }, []);
407
+
380
408
  // Serve the requested review scope from a capture when that scope exists.
381
409
  const seedFromCapture = useCallback(
382
410
  (manifest: WorkspaceCaptureManifest) => {
@@ -432,9 +460,17 @@ export function useSandboxGit(
432
460
  // true data-identity change clears rendered state and the event high-water mark.
433
461
  refreshAbortRef.current?.abort();
434
462
  refreshAbortRef.current = null;
463
+ refreshPendingRef.current = false;
435
464
  refreshGenerationRef.current += 1;
436
465
  const identityChanged = previousIdentityRef.current !== identityKey;
437
466
  previousIdentityRef.current = identityKey;
467
+ if (identityChanged) {
468
+ // Different sessions target different provider identities. Fence the old
469
+ // worker and let the new session load immediately instead of waiting for a
470
+ // stuck request that has already been aborted.
471
+ refreshWorkerEpochRef.current += 1;
472
+ refreshInFlightRef.current = null;
473
+ }
438
474
  if (identityChanged || !enabled) {
439
475
  lastChangeRef.current = 0;
440
476
  setDiff([]);
@@ -466,6 +502,7 @@ export function useSandboxGit(
466
502
  return () => {
467
503
  refreshAbortRef.current?.abort();
468
504
  refreshAbortRef.current = null;
505
+ refreshPendingRef.current = false;
469
506
  refreshGenerationRef.current += 1;
470
507
  };
471
508
  }, [
@@ -479,15 +516,15 @@ export function useSandboxGit(
479
516
  seedFromCapture,
480
517
  ]);
481
518
 
482
- // git.changed re-fetch the LIVE diff. Agent tool completion is also an
483
- // invalidation point because shell/apply-patch writes can bypass Channel-A and
484
- // therefore emit no git.changed event. This is event-driven, never polling.
519
+ // Structured Git changes are debounced into one live reconcile. Generic
520
+ // command/tool output performs no provider reads: the canonical turn-end
521
+ // capture advances `captureRevision`, which refreshes after the burst settles.
485
522
  const events = options.events;
486
523
  useEffect(() => {
487
524
  if (!enabled || !events) {
488
- if (commandRefreshTimerRef.current) {
489
- clearTimeout(commandRefreshTimerRef.current);
490
- commandRefreshTimerRef.current = null;
525
+ if (eventRefreshTimerRef.current) {
526
+ clearTimeout(eventRefreshTimerRef.current);
527
+ eventRefreshTimerRef.current = null;
491
528
  }
492
529
  return;
493
530
  }
@@ -498,43 +535,35 @@ export function useSandboxGit(
498
535
  for (const event of events) {
499
536
  if (event.sequence > lastChangeRef.current) lastChangeRef.current = event.sequence;
500
537
  }
501
- if (commandRefreshTimerRef.current) {
502
- clearTimeout(commandRefreshTimerRef.current);
503
- commandRefreshTimerRef.current = null;
538
+ if (eventRefreshTimerRef.current) {
539
+ clearTimeout(eventRefreshTimerRef.current);
540
+ eventRefreshTimerRef.current = null;
504
541
  }
505
542
  return;
506
543
  }
507
544
  let latest = lastChangeRef.current;
508
- let immediate = false;
509
- let commandDelta = false;
545
+ let changed = false;
510
546
  for (const event of events) {
511
547
  if (event.sequence <= latest) continue;
512
- if (event.type === "git.changed" || event.type === "agent.toolCall.output") {
513
- latest = event.sequence;
514
- immediate = true;
515
- } else if (event.type === "sandbox.command.output.delta") {
548
+ if (event.type === "git.changed") {
516
549
  latest = event.sequence;
517
- commandDelta = true;
550
+ changed = true;
518
551
  }
519
552
  }
520
553
  if (latest > lastChangeRef.current) {
521
554
  lastChangeRef.current = latest;
522
- if (immediate) {
523
- if (commandRefreshTimerRef.current) clearTimeout(commandRefreshTimerRef.current);
524
- commandRefreshTimerRef.current = null;
555
+ if (!changed) return;
556
+ if (eventRefreshTimerRef.current) clearTimeout(eventRefreshTimerRef.current);
557
+ eventRefreshTimerRef.current = setTimeout(() => {
558
+ eventRefreshTimerRef.current = null;
525
559
  if (acceptsEventReadsRef.current) void refresh();
526
- } else if (commandDelta && !commandRefreshTimerRef.current) {
527
- commandRefreshTimerRef.current = setTimeout(() => {
528
- commandRefreshTimerRef.current = null;
529
- if (acceptsEventReadsRef.current) void refresh();
530
- }, 1_000);
531
- }
560
+ }, 150);
532
561
  }
533
562
  }, [enabled, active, acceptsEventReads, events, refresh]);
534
563
 
535
564
  useEffect(
536
565
  () => () => {
537
- if (commandRefreshTimerRef.current) clearTimeout(commandRefreshTimerRef.current);
566
+ if (eventRefreshTimerRef.current) clearTimeout(eventRefreshTimerRef.current);
538
567
  },
539
568
  [],
540
569
  );
@@ -0,0 +1,56 @@
1
+ import type { SessionBackgroundCommand } from "@opengeni/sdk";
2
+ import { useCallback } from "react";
3
+
4
+ import { useOpenGeni, type ClientOverride } from "../provider";
5
+ import { usePolledValue } from "./internal";
6
+
7
+ export type UseSessionBackgroundCommandsOptions = ClientOverride & {
8
+ pollIntervalMs?: number | undefined;
9
+ enabled?: boolean | undefined;
10
+ };
11
+
12
+ export type UseSessionBackgroundCommandsResult = {
13
+ commands: SessionBackgroundCommand[];
14
+ loading: boolean;
15
+ error: Error | null;
16
+ refresh: () => Promise<void>;
17
+ cancel: (commandId: string) => Promise<void>;
18
+ };
19
+
20
+ export function useSessionBackgroundCommands(
21
+ sessionId: string | null | undefined,
22
+ options: UseSessionBackgroundCommandsOptions = {},
23
+ ): UseSessionBackgroundCommandsResult {
24
+ const { client, workspaceId } = useOpenGeni(options);
25
+ const enabled = (options.enabled ?? true) && Boolean(sessionId);
26
+ const load = useCallback(async () => {
27
+ if (!sessionId) return { commands: [] };
28
+ if (!client.listSessionBackgroundCommands) {
29
+ throw new Error("The configured OpenGeni client does not support background commands");
30
+ }
31
+ return await client.listSessionBackgroundCommands(workspaceId, sessionId);
32
+ }, [client, workspaceId, sessionId]);
33
+ const state = usePolledValue(load, {
34
+ enabled,
35
+ pollIntervalMs: options.pollIntervalMs,
36
+ });
37
+ const refresh = state.refresh;
38
+ const cancel = useCallback(
39
+ async (commandId: string): Promise<void> => {
40
+ if (!sessionId) return;
41
+ if (!client.cancelSessionBackgroundCommand) {
42
+ throw new Error("The configured OpenGeni client does not support background commands");
43
+ }
44
+ await client.cancelSessionBackgroundCommand(workspaceId, sessionId, commandId);
45
+ await refresh();
46
+ },
47
+ [client, workspaceId, sessionId, refresh],
48
+ );
49
+ return {
50
+ commands: state.data?.commands ?? [],
51
+ loading: state.loading,
52
+ error: state.error,
53
+ refresh,
54
+ cancel,
55
+ };
56
+ }
@@ -283,6 +283,11 @@ export function useSessionCapabilities(
283
283
  })
284
284
  .catch((cause) => {
285
285
  if (!cancelled) {
286
+ if (isSandboxOwnershipDisabledError(cause)) {
287
+ setState("on-demand");
288
+ setError(null);
289
+ return;
290
+ }
286
291
  setState("error");
287
292
  setError(cause instanceof Error ? cause : new Error(String(cause)));
288
293
  }
@@ -453,6 +458,11 @@ export function useSessionCapabilities(
453
458
  }
454
459
  } catch (cause) {
455
460
  if (cancelled) return;
461
+ if (isSandboxOwnershipDisabledError(cause)) {
462
+ setState("on-demand");
463
+ setError(null);
464
+ return;
465
+ }
456
466
  if (cause instanceof OpenGeniApiError && cause.status === 403) {
457
467
  setState("error");
458
468
  setError(new Error("not permitted to view this session's sandbox"));
@@ -524,3 +534,11 @@ export function useSessionCapabilities(
524
534
  renegotiate,
525
535
  };
526
536
  }
537
+
538
+ function isSandboxOwnershipDisabledError(error: unknown): boolean {
539
+ return (
540
+ error instanceof OpenGeniApiError &&
541
+ error.status === 404 &&
542
+ error.message.includes("sandbox ownership is not enabled")
543
+ );
544
+ }
@@ -42,6 +42,17 @@ function queueSnapshotCovers(
42
42
  export type QueueMutationKind = "move" | "edit" | "steer" | "delete";
43
43
  const EMPTY_PENDING_BY_TURN: Readonly<Record<string, QueueMutationKind>> = {};
44
44
 
45
+ export type AcceptedQueueSteer = {
46
+ turnId: string;
47
+ triggerEventId: string;
48
+ text: string;
49
+ annotations: SessionTurn["annotations"];
50
+ resources: SessionTurn["resources"];
51
+ tools: SessionTurn["tools"];
52
+ occurredAt: string;
53
+ state: "sending" | "queued";
54
+ };
55
+
45
56
  export type UseTurnQueueOptions = EmbeddedSessionClientOverride &
46
57
  SessionEventFeedOptions & {
47
58
  pollIntervalMs?: number | undefined;
@@ -77,6 +88,8 @@ export type UseTurnQueueResult = {
77
88
  mutating: boolean;
78
89
  mutationError: Error | null;
79
90
  clearMutationError: () => void;
91
+ /** Immediate chat bridge while the durable Steer event catches up over SSE. */
92
+ acceptedSteers?: AcceptedQueueSteer[] | undefined;
80
93
  };
81
94
 
82
95
  /**
@@ -99,6 +112,7 @@ export function useTurnQueue(
99
112
  const [error, setError] = useState<Error | null>(null);
100
113
  const [mutationError, setMutationError] = useState<Error | null>(null);
101
114
  const [pendingByTurn, setPendingByTurn] = useState<Record<string, QueueMutationKind>>({});
115
+ const [acceptedSteers, setAcceptedSteers] = useState<AcceptedQueueSteer[]>([]);
102
116
  const pendingRef = useRef<Record<string, QueueMutationKind>>({});
103
117
  const readGeneration = useRef(0);
104
118
  const targetKeyRef = useRef(targetKey);
@@ -118,8 +132,26 @@ export function useTurnQueue(
118
132
  setError(null);
119
133
  setMutationError(null);
120
134
  setPendingByTurn({});
135
+ setAcceptedSteers([]);
121
136
  }, [enabled, targetKey]);
122
137
 
138
+ useEffect(() => {
139
+ if (acceptedSteers.length === 0 || options.events === undefined) return;
140
+ const durablyStartedTurnIds = new Set(
141
+ options.events.flatMap((event) =>
142
+ event.type === "turn.started" && event.turnId ? [event.turnId] : [],
143
+ ),
144
+ );
145
+ if (durablyStartedTurnIds.size === 0) return;
146
+ // steer_requested can reach the browser before the timeline projection
147
+ // that moves the queued user.message into chat. Keep this bridge through
148
+ // physical cancellation; turn.started is the first boundary at which the
149
+ // replacement timeline must already be visible.
150
+ setAcceptedSteers((current) =>
151
+ current.filter((steer) => !durablyStartedTurnIds.has(steer.turnId)),
152
+ );
153
+ }, [acceptedSteers.length, options.events]);
154
+
123
155
  const acceptSnapshot = useCallback(
124
156
  (ownedTargetKey: string, next: SessionQueueSnapshot | null): boolean => {
125
157
  if (targetKeyRef.current !== ownedTargetKey) return false;
@@ -234,6 +266,7 @@ export function useTurnQueue(
234
266
  command: (
235
267
  current: SessionQueueSnapshot,
236
268
  turn: SessionTurn,
269
+ clientEventId: string,
237
270
  ) => Promise<SessionQueueMutationResponse>,
238
271
  ): Promise<SessionQueueMutationResponse | null> => {
239
272
  const ownedTargetKey = targetKey;
@@ -247,8 +280,17 @@ export function useTurnQueue(
247
280
  setStateTargetKey(ownedTargetKey);
248
281
  setPendingByTurn(pendingRef.current);
249
282
  setMutationError(null);
283
+ const clientEventId = operationKey();
250
284
  try {
251
- const result = await command(current, turn);
285
+ let result: SessionQueueMutationResponse;
286
+ try {
287
+ result = await command(current, turn, clientEventId);
288
+ } catch (cause) {
289
+ if (!isOutcomeUnknownError(cause)) throw cause;
290
+ // Same-key replay is a fact check against the durable receipt. It
291
+ // cannot duplicate the command even if the first response was lost.
292
+ result = await command(current, turn, clientEventId);
293
+ }
252
294
  if (targetKeyRef.current !== ownedTargetKey) return null;
253
295
  acceptSnapshot(ownedTargetKey, result.snapshot);
254
296
  return result;
@@ -272,9 +314,9 @@ export function useTurnQueue(
272
314
 
273
315
  const moveTurn = useCallback(
274
316
  async (turnId: string, beforeTurnId: string | null): Promise<boolean> => {
275
- const result = await mutate(turnId, "move", (current) =>
317
+ const result = await mutate(turnId, "move", (current, _turn, clientEventId) =>
276
318
  client.moveQueueItem(workspaceId, sessionId!, turnId, {
277
- clientEventId: operationKey(),
319
+ clientEventId,
278
320
  expectedQueueVersion: current.version,
279
321
  beforeTurnId,
280
322
  }),
@@ -289,9 +331,9 @@ export function useTurnQueue(
289
331
  turnId: string,
290
332
  edit: { expectedDraftRevision: number; replaceDraft: boolean },
291
333
  ): Promise<ComposerDraft | null> => {
292
- const result = await mutate(turnId, "edit", (_current, turn) =>
334
+ const result = await mutate(turnId, "edit", (_current, turn, clientEventId) =>
293
335
  client.editQueueItem(workspaceId, sessionId!, turnId, {
294
- clientEventId: operationKey(),
336
+ clientEventId,
295
337
  expectedTurnVersion: turn.version,
296
338
  expectedDraftRevision: edit.expectedDraftRevision,
297
339
  replaceDraft: edit.replaceDraft,
@@ -304,23 +346,71 @@ export function useTurnQueue(
304
346
 
305
347
  const steerTurn = useCallback(
306
348
  async (turnId: string): Promise<boolean> => {
307
- const result = await mutate(turnId, "steer", (current, turn) =>
349
+ const ownedTargetKey = targetKey;
350
+ const turn = snapshotRef.current?.items.find((candidate) => candidate.id === turnId);
351
+ if (targetKeyRef.current !== ownedTargetKey) return false;
352
+ if (!turn) {
353
+ setMutationError(new Error("That queued prompt changed before Steer could be sent."));
354
+ await load();
355
+ return false;
356
+ }
357
+ const optimistic: AcceptedQueueSteer = {
358
+ turnId: turn.id,
359
+ triggerEventId: turn.triggerEventId,
360
+ text: turn.prompt,
361
+ annotations: turn.annotations ?? [],
362
+ resources: turn.resources,
363
+ tools: turn.tools,
364
+ occurredAt: new Date().toISOString(),
365
+ state: "sending",
366
+ };
367
+ setAcceptedSteers((current) => [
368
+ ...current.filter((steer) => steer.turnId !== turnId),
369
+ optimistic,
370
+ ]);
371
+ const result = await mutate(turnId, "steer", (current, queuedTurn, clientEventId) =>
308
372
  client.steerQueueItem(workspaceId, sessionId!, turnId, {
309
- clientEventId: operationKey(),
310
- expectedTurnVersion: turn.version,
373
+ clientEventId,
374
+ expectedTurnVersion: queuedTurn.version,
311
375
  controlEtag: current.effectiveControl.controlEtag,
312
376
  }),
313
377
  );
314
- return result !== null;
378
+ if (targetKeyRef.current !== ownedTargetKey) return false;
379
+ // The active turn can finish while the operator clicks Steer. In that
380
+ // race the queued prompt is atomically claimed before the steer command,
381
+ // so the command conflicts but the user's prompt already advanced into
382
+ // chat. Reconciled absence is success, not a scary unknown-outcome lie.
383
+ const advancedWithoutSteer =
384
+ result === null &&
385
+ snapshotRef.current !== null &&
386
+ !snapshotRef.current.items.some((candidate) => candidate.id === turnId);
387
+ if (advancedWithoutSteer) {
388
+ setMutationError(null);
389
+ setAcceptedSteers((current) => current.filter((steer) => steer.turnId !== turnId));
390
+ return true;
391
+ }
392
+ const accepted =
393
+ result !== null ||
394
+ snapshotRef.current?.items.some(
395
+ (candidate) => candidate.id === turnId && candidate.metadata.delivery === "steer",
396
+ ) === true;
397
+ setAcceptedSteers((current) =>
398
+ accepted
399
+ ? current.map((steer) =>
400
+ steer.turnId === turnId ? { ...steer, state: "queued" as const } : steer,
401
+ )
402
+ : current.filter((steer) => steer.turnId !== turnId),
403
+ );
404
+ return accepted;
315
405
  },
316
- [client, mutate, sessionId, workspaceId],
406
+ [client, load, mutate, sessionId, targetKey, workspaceId],
317
407
  );
318
408
 
319
409
  const removeTurn = useCallback(
320
410
  async (turnId: string): Promise<boolean> => {
321
- const result = await mutate(turnId, "delete", (_current, turn) =>
411
+ const result = await mutate(turnId, "delete", (_current, turn, clientEventId) =>
322
412
  client.deleteQueueItem(workspaceId, sessionId!, turnId, {
323
- clientEventId: operationKey(),
413
+ clientEventId,
324
414
  expectedTurnVersion: turn.version,
325
415
  reason: "Deleted from the prompt queue",
326
416
  }),
@@ -364,6 +454,7 @@ export function useTurnQueue(
364
454
  clearMutationError: useCallback(() => {
365
455
  if (targetKeyRef.current === targetKey) setMutationError(null);
366
456
  }, [targetKey]),
457
+ acceptedSteers: identityMatches ? acceptedSteers : [],
367
458
  };
368
459
  }
369
460
 
@@ -374,3 +465,11 @@ function operationKey(): string {
374
465
  function asError(cause: unknown): Error {
375
466
  return cause instanceof Error ? cause : new Error(String(cause));
376
467
  }
468
+
469
+ function isOutcomeUnknownError(cause: unknown): boolean {
470
+ return (
471
+ typeof cause === "object" &&
472
+ cause !== null &&
473
+ (cause as { outcomeUnknown?: unknown }).outcomeUnknown === true
474
+ );
475
+ }
@@ -10,6 +10,7 @@ export type UseWorkspaceSessionsOptions = ClientOverride & {
10
10
  search?: string | undefined;
11
11
  /** Return only the complete personal pinned projection. */
12
12
  pinsOnly?: boolean | undefined;
13
+ archivedOnly?: boolean | undefined;
13
14
  /** Refresh interval (ms) for fleet/manager views. Off by default. */
14
15
  pollIntervalMs?: number | undefined;
15
16
  enabled?: boolean | undefined;
@@ -41,6 +42,7 @@ export function useWorkspaceSessions(
41
42
  const cursor = options.cursor;
42
43
  const search = options.search;
43
44
  const pinsOnly = options.pinsOnly;
45
+ const archivedOnly = options.archivedOnly;
44
46
  const enabled = options.enabled ?? true;
45
47
  const queryKey = [
46
48
  workspaceId,
@@ -49,6 +51,7 @@ export function useWorkspaceSessions(
49
51
  cursor ?? "",
50
52
  search ?? "",
51
53
  pinsOnly ? "1" : "",
54
+ archivedOnly ? "archived" : "active",
52
55
  ].join("\u0000");
53
56
  const previousQueryKey = useRef(queryKey);
54
57
  const queryKeyTransition = previousQueryKey.current !== queryKey;
@@ -64,9 +67,10 @@ export function useWorkspaceSessions(
64
67
  ...(cursor !== undefined ? { cursor } : {}),
65
68
  ...(search !== undefined ? { search } : {}),
66
69
  ...(pinsOnly ? { pinsOnly: true } : {}),
70
+ ...(archivedOnly ? { archivedOnly: true } : {}),
67
71
  }),
68
72
  }),
69
- [client, workspaceId, limit, parentSessionId, cursor, search, pinsOnly, queryKey],
73
+ [client, workspaceId, limit, parentSessionId, cursor, search, pinsOnly, archivedOnly, queryKey],
70
74
  );
71
75
  const state = usePolledValue(load, {
72
76
  pollIntervalMs: options.pollIntervalMs,
package/src/index.ts CHANGED
@@ -54,7 +54,13 @@ export {
54
54
  shouldSubmitOnKey,
55
55
  FILE_ONLY_MESSAGE_TEXT,
56
56
  } from "./hooks/use-composer";
57
- export type { ComposerSendExtras, ComposerState, UseComposerOptions } from "./hooks/use-composer";
57
+ export type {
58
+ ComposerControllerState,
59
+ ComposerPolicy,
60
+ ComposerSendExtras,
61
+ ComposerState,
62
+ UseComposerOptions,
63
+ } from "./hooks/use-composer";
58
64
  export {
59
65
  VOICE_RECORDING_CLIENT_MAX_DURATION_SECONDS,
60
66
  VOICE_RECORDING_OWNER_HEARTBEAT_MILLISECONDS,
@@ -129,7 +135,12 @@ export type {
129
135
  } from "./hooks/use-last-started-turn-policy";
130
136
  export { QueueSurface } from "./components/queue-surface";
131
137
  export type { QueueSurfaceProps } from "./components/queue-surface";
132
- export { SessionChrome, sessionChromeGoalPillState } from "./components/session-chrome";
138
+ export {
139
+ SessionChrome,
140
+ sessionChromeGoalPillExplanation,
141
+ sessionChromeGoalPillLabel,
142
+ sessionChromeGoalPillState,
143
+ } from "./components/session-chrome";
133
144
  export type {
134
145
  SessionChromeAgentsSignal,
135
146
  SessionChromeProps,
@@ -163,6 +174,11 @@ export type {
163
174
  UseWorkspaceSessionsOptions,
164
175
  UseWorkspaceSessionsResult,
165
176
  } from "./hooks/use-workspace-sessions";
177
+ export { useSessionBackgroundCommands } from "./hooks/use-session-background-commands";
178
+ export type {
179
+ UseSessionBackgroundCommandsOptions,
180
+ UseSessionBackgroundCommandsResult,
181
+ } from "./hooks/use-session-background-commands";
166
182
  export { useSessionLineage, isLineageRefreshEvent } from "./hooks/use-session-lineage";
167
183
  export type {
168
184
  UseSessionLineageOptions,
@@ -315,6 +331,8 @@ export type {
315
331
  AuthNeededItem,
316
332
  ContextCompactionItem,
317
333
  GoalItem,
334
+ HumanInputAnswerSummary,
335
+ HumanInputItem,
318
336
  MachineInputBatchItem,
319
337
  MachineInputMember,
320
338
  NoticeItem,
@@ -478,10 +496,14 @@ export type { GeneratedVideoPlayerProps } from "./components/generated-video-pla
478
496
  export type { MessageTimelineProps } from "./components/message-timeline";
479
497
  export { UserMessageBody, userMessageLikelyNeedsDisclosure } from "./components/user-message-body";
480
498
  export type { UserMessageBodyProps } from "./components/user-message-body";
481
- export { Markdown } from "./components/markdown";
499
+ export {
500
+ Markdown,
501
+ sandboxFileLocationFromHref,
502
+ sandboxFilePathFromHref,
503
+ } from "./components/markdown";
504
+ export type { MarkdownProps, SandboxFileLocation } from "./components/markdown";
482
505
  export { CopyButton, CopyHoverFrame } from "./components/copy-button";
483
506
  export { copyTextToClipboard, tableElementToTsv } from "./lib/clipboard";
484
- export type { MarkdownProps } from "./components/markdown";
485
507
  export { SessionStatus, StatusDot, SESSION_STATUS_META } from "./components/session-status";
486
508
  export type {
487
509
  SessionStatusProps,
@@ -0,0 +1,18 @@
1
+ import { OpenGeniApiError } from "@opengeni/sdk";
2
+
3
+ export function isNonRetryableInteractionError(error: unknown): error is OpenGeniApiError {
4
+ return error instanceof OpenGeniApiError && error.retryable === false;
5
+ }
6
+
7
+ export function isSourcePlacementChangedError(
8
+ error: unknown,
9
+ resource: "browser_session" | "computer_session",
10
+ ): error is OpenGeniApiError {
11
+ return (
12
+ isNonRetryableInteractionError(error) &&
13
+ error.status === 409 &&
14
+ error.details?.interactionResource === resource &&
15
+ error.details?.interactionFailureCode === "source_placement_changed" &&
16
+ error.details?.interactionLifecycle === "lost"
17
+ );
18
+ }
package/src/machines.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  export type {
10
10
  ConnectionStatus,
11
11
  MachineKind,
12
+ MachineOperationPolicy,
12
13
  MachineMetricsSeriesResponse,
13
14
  MachineState,
14
15
  MachineView,
@@ -48,6 +49,8 @@ export type {
48
49
  } from "./components/machines/metric-history-chart";
49
50
  export { MachineDetail } from "./components/machines/machine-detail";
50
51
  export type { MachineDetailProps } from "./components/machines/machine-detail";
52
+ export { MachineOperationPolicyEditor } from "./components/machines/machine-operation-policy";
53
+ export type { MachineOperationPolicyEditorProps } from "./components/machines/machine-operation-policy";
51
54
  export { METRICS, METRIC_WINDOWS, WINDOW_LABEL, pointsFor } from "./components/machines/series";
52
55
  export type { MetricDef, MetricKey, MetricWindow } from "./components/machines/series";
53
56
  export { MachinesDashboard } from "./components/machines-dashboard";
@@ -68,7 +71,12 @@ export type {
68
71
  EnrollmentConsentPhase,
69
72
  EnrollmentConsentProps,
70
73
  } from "./components/enrollment-consent";
71
- export { useMachines } from "./hooks/use-machines";
74
+ export {
75
+ useMachines,
76
+ MACHINES_COMPOSER_POLL_MS,
77
+ MACHINES_DASHBOARD_POLL_MS,
78
+ MACHINES_SESSION_POLL_MS,
79
+ } from "./hooks/use-machines";
72
80
  export type {
73
81
  MachinesClientLike,
74
82
  UseMachinesOptions,
@@ -2,6 +2,7 @@ import type { ClientModel, ReasoningEffort, WorkspaceModelCatalogModel } from "@
2
2
 
3
3
  export type PickerBillingClass =
4
4
  | "opengeni_credits"
5
+ | "external"
5
6
  | "codex_subscription"
6
7
  | "supergrok_subscription"
7
8
  | "byok";
@@ -24,6 +25,7 @@ export type LatencyModeId = "standard" | "priority" | "fast";
24
25
 
25
26
  const BILLING_CLASS_ORDER: PickerBillingClass[] = [
26
27
  "opengeni_credits",
28
+ "external",
27
29
  "codex_subscription",
28
30
  "supergrok_subscription",
29
31
  "byok",
@@ -31,6 +33,7 @@ const BILLING_CLASS_ORDER: PickerBillingClass[] = [
31
33
 
32
34
  const BILLING_CLASS_LABELS: Record<PickerBillingClass, string> = {
33
35
  opengeni_credits: "OpenGeni",
36
+ external: "External",
34
37
  codex_subscription: "Codex",
35
38
  supergrok_subscription: "SuperGrok",
36
39
  byok: "Your Gateway",
@@ -47,6 +50,9 @@ const AVAILABILITY_REASON_LABELS: Record<string, string> = {
47
50
  };
48
51
 
49
52
  export function billingClassForModel(model: ClientModel): PickerBillingClass {
53
+ if (model.billing?.metering === "external" && model.billing.upstreamPayer === "deployment") {
54
+ return "external";
55
+ }
50
56
  if (model.source === "supergrok") {
51
57
  return "supergrok_subscription";
52
58
  }
@@ -153,13 +159,15 @@ export function payerSummaryForModel(model: ClientModel): string {
153
159
  if (billing.upstreamPayer === "workspace") {
154
160
  return "Billed to your AI Gateway";
155
161
  }
156
- return "Deployment route · external billing";
162
+ return "External provider · no OpenGeni credits";
157
163
  }
158
164
 
159
165
  export function advancedSourceSummary(model: ClientModel): string | null {
160
166
  const source = model.credentialSource;
161
167
  if (!source) {
162
- return null;
168
+ return model.billing?.metering === "external" && model.billing.upstreamPayer === "deployment"
169
+ ? "Deployment route · no authentication"
170
+ : null;
163
171
  }
164
172
  if (source.kind === "connected_subscription") {
165
173
  return source.provider === "xai"