@opengeni/react 0.52.1 → 0.57.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 (132) hide show
  1. package/README.md +48 -0
  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 +2 -2
  66. package/dist/realtime.js +36 -9
  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 +34 -6
  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
@@ -4,8 +4,16 @@ import type {
4
4
  DesktopStreamCapability,
5
5
  } from "@opengeni/sdk";
6
6
  import { LoaderCircleIcon, MonitorIcon, MousePointerClickIcon, WifiOffIcon } from "lucide-react";
7
- import { type ReactNode, useEffect, useRef, useState } from "react";
7
+ import {
8
+ type ClipboardEvent,
9
+ type KeyboardEvent as ReactKeyboardEvent,
10
+ type ReactNode,
11
+ useEffect,
12
+ useRef,
13
+ useState,
14
+ } from "react";
8
15
  import { cn } from "../lib/cn";
16
+ import { interactionHostPlatform, type InteractionHostPlatform } from "../lib/host-platform";
9
17
  import { useDesktopStream } from "../hooks/use-desktop-stream";
10
18
  import type { DesktopWebSocketFactory } from "../hooks/use-relay-frame-stream";
11
19
 
@@ -24,9 +32,22 @@ export type DesktopViewerProps = {
24
32
  scaleViewport?: boolean | undefined;
25
33
  /** Custom RFB factory (tests / a WebRTC swap). Defaults to lazy @novnc/novnc. */
26
34
  rfbFactory?: DesktopRfbFactory | undefined;
35
+ /** Authenticated controller protocols for proxied RFB transports. */
36
+ webSocketProtocols?: string[] | undefined;
27
37
  /** Custom socket factory for the self-hosted `relay-frames` transport (tests).
28
38
  * Defaults to `new WebSocket(url)`. Mirrors `rfbFactory`. */
29
39
  webSocketFactory?: DesktopWebSocketFactory | undefined;
40
+ /**
41
+ * Optional host-owned paste path. ComputerSession hosts use their native,
42
+ * target-scoped clipboard action instead of relying on an RFB server to sync
43
+ * ClientCutText into the graphical seat. Return true once the host accepted
44
+ * the text. Without a host authority the viewer leaves paste untouched rather
45
+ * than claiming success for an unreliable display-server clipboard heuristic.
46
+ */
47
+ onPasteText?: ((text: string) => boolean) | undefined;
48
+ /** OS behind the RFB seat. Used only to translate the host's primary shortcut
49
+ * (Cmd on macOS, Ctrl elsewhere) to the target's primary modifier. */
50
+ targetPlatform?: "linux" | "macos" | "windows" | null | undefined;
30
51
  /**
31
52
  * @deprecated Desktop acknowledgment is automatic. Retained as an ignored
32
53
  * compatibility prop so existing embedders keep compiling.
@@ -138,7 +159,10 @@ export function DesktopViewer({
138
159
  showControlToggle = true,
139
160
  scaleViewport,
140
161
  rfbFactory,
162
+ webSocketProtocols,
141
163
  webSocketFactory,
164
+ onPasteText,
165
+ targetPlatform,
142
166
  onActivate,
143
167
  onAcknowledge,
144
168
  watching,
@@ -273,6 +297,7 @@ export function DesktopViewer({
273
297
  interactive: inControl,
274
298
  ...(scaleViewport !== undefined ? { scaleViewport } : {}),
275
299
  ...(rfbFactory ? { rfbFactory } : {}),
300
+ ...(webSocketProtocols ? { webSocketProtocols } : {}),
276
301
  ...(webSocketFactory ? { webSocketFactory } : {}),
277
302
  });
278
303
 
@@ -373,6 +398,45 @@ export function DesktopViewer({
373
398
  stream.reconnect();
374
399
  };
375
400
 
401
+ const paste = (event: ClipboardEvent<HTMLDivElement>) => {
402
+ if (!inControl) return;
403
+ const text = event.clipboardData.getData("text/plain");
404
+ if (!text && !event.clipboardData.types.includes("text/plain")) return;
405
+ if (!onPasteText?.(text)) return;
406
+ event.preventDefault();
407
+ event.stopPropagation();
408
+ };
409
+
410
+ const primaryShortcut = (event: ReactKeyboardEvent<HTMLDivElement>) => {
411
+ if (!inControl || !targetPlatform) return;
412
+ const hostPlatform = interactionHostPlatform();
413
+ // noVNC maps a Mac Command key to remote Alt before the actual chord key
414
+ // arrives. Suppress that standalone physical modifier; the complete target
415
+ // chord below owns both its down and up events.
416
+ if (isHostPrimaryModifier(event, hostPlatform)) {
417
+ event.stopPropagation();
418
+ return;
419
+ }
420
+ const chord = desktopPrimaryShortcut(event, hostPlatform, targetPlatform);
421
+ if (!chord) return;
422
+ // Copy/paste must keep the browser's default clipboard dispatch alive, but
423
+ // noVNC must not also send the host modifier into the remote seat.
424
+ if (chord === "clipboard") {
425
+ event.stopPropagation();
426
+ return;
427
+ }
428
+ const sent = chord.every(({ keysym, code, down }) => stream.sendKey?.(keysym, code, down));
429
+ if (!sent) return;
430
+ event.preventDefault();
431
+ event.stopPropagation();
432
+ };
433
+
434
+ const primaryModifierUp = (event: ReactKeyboardEvent<HTMLDivElement>) => {
435
+ if (inControl && isHostPrimaryModifier(event, interactionHostPlatform())) {
436
+ event.stopPropagation();
437
+ }
438
+ };
439
+
376
440
  // ── Resolve the single UI state (priority order) ────────────────────────────
377
441
  let uiState: DesktopUiState;
378
442
  if (viewerCapReached) uiState = "viewer_cap";
@@ -448,6 +512,10 @@ export function DesktopViewer({
448
512
  data-state={stream.state}
449
513
  data-ui-state={uiState}
450
514
  data-in-control={inControl ? "true" : undefined}
515
+ data-target-platform={targetPlatform ?? undefined}
516
+ onKeyDownCapture={primaryShortcut}
517
+ onKeyUpCapture={primaryModifierUp}
518
+ onPasteCapture={paste}
451
519
  >
452
520
  {/* noVNC mount target. It appends a `width:100%;height:100%` `_screen` div
453
521
  and AUTOSCALES the 1280x800 framebuffer to fit THIS box. We pin it to
@@ -524,6 +592,51 @@ export function DesktopViewer({
524
592
  );
525
593
  }
526
594
 
595
+ type RfbKeyEvent = { keysym: number; code: string; down: boolean };
596
+
597
+ function isHostPrimaryModifier(
598
+ event: Pick<ReactKeyboardEvent<HTMLDivElement>, "key">,
599
+ hostPlatform: InteractionHostPlatform,
600
+ ): boolean {
601
+ return event.key === (hostPlatform === "mac" ? "Meta" : "Control");
602
+ }
603
+
604
+ /** Translate one host-primary chord into deterministic RFB events for the
605
+ * target OS. noVNC otherwise treats a Mac Command key as remote Alt, which
606
+ * makes ordinary Cmd+A/Z shortcuts silently wrong on Linux sandboxes. */
607
+ export function desktopPrimaryShortcut(
608
+ event: Pick<
609
+ ReactKeyboardEvent<HTMLDivElement>,
610
+ "altKey" | "ctrlKey" | "key" | "metaKey" | "shiftKey" | "code"
611
+ >,
612
+ hostPlatform: InteractionHostPlatform,
613
+ targetPlatform: "linux" | "macos" | "windows",
614
+ ): RfbKeyEvent[] | "clipboard" | null {
615
+ const primaryHeld = hostPlatform === "mac" ? event.metaKey : event.ctrlKey;
616
+ if (!primaryHeld || [...event.key].length !== 1) return null;
617
+ const key = event.key.toLowerCase();
618
+ if (!event.altKey && (key === "c" || key === "v")) return "clipboard";
619
+ const primary =
620
+ targetPlatform === "macos"
621
+ ? { keysym: 0xffeb, code: "MetaLeft" }
622
+ : { keysym: 0xffe3, code: "ControlLeft" };
623
+ const modifiers = [
624
+ primary,
625
+ ...(event.altKey ? [{ keysym: 0xffe9, code: "AltLeft" }] : []),
626
+ ...(event.shiftKey ? [{ keysym: 0xffe1, code: "ShiftLeft" }] : []),
627
+ ];
628
+ const keyEvent = {
629
+ keysym: key.codePointAt(0)!,
630
+ code: event.code || (/^[a-z]$/u.test(key) ? `Key${key.toUpperCase()}` : key),
631
+ };
632
+ return [
633
+ ...modifiers.map((modifier) => ({ ...modifier, down: true })),
634
+ { ...keyEvent, down: true },
635
+ { ...keyEvent, down: false },
636
+ ...modifiers.reverse().map((modifier) => ({ ...modifier, down: false })),
637
+ ];
638
+ }
639
+
527
640
  /**
528
641
  * The primary WATCHING-state call-to-action: a large, centered pill button
529
642
  * overlaid on the desktop inviting the viewer to drive the mouse & keyboard.
@@ -0,0 +1,99 @@
1
+ import type { InteractionIntervention } from "@opengeni/sdk/interaction";
2
+ import { CheckIcon, CircleAlertIcon, LoaderCircleIcon, XIcon } from "lucide-react";
3
+ import { cn } from "../lib/cn";
4
+
5
+ export type InteractionInterventionBannerProps = {
6
+ interventions: InteractionIntervention[];
7
+ activeTargetId?: string | null | undefined;
8
+ mutating?: boolean | undefined;
9
+ className?: string | undefined;
10
+ onOpen?: ((intervention: InteractionIntervention) => void) | undefined;
11
+ onResolve: (intervention: InteractionIntervention, outcome: "completed" | "dismissed") => void;
12
+ };
13
+
14
+ /** Compact durable human-action prompt shared by Browser and Computer viewers. */
15
+ export function InteractionInterventionBanner({
16
+ interventions,
17
+ activeTargetId,
18
+ mutating = false,
19
+ className,
20
+ onOpen,
21
+ onResolve,
22
+ }: InteractionInterventionBannerProps) {
23
+ const intervention = interventions[0];
24
+ if (!intervention) return null;
25
+ const targetOpen = activeTargetId === undefined || activeTargetId === intervention.targetId;
26
+ return (
27
+ <div
28
+ className={cn(
29
+ "flex shrink-0 items-center gap-2 border-b border-og-status-waiting/25 bg-og-status-waiting/[0.07] px-3 py-2",
30
+ className,
31
+ )}
32
+ role="status"
33
+ >
34
+ <span className="grid size-7 shrink-0 place-items-center rounded-og-sm bg-og-status-waiting/10 text-og-status-waiting">
35
+ <CircleAlertIcon className="size-3.5" />
36
+ </span>
37
+ <span className="min-w-0 flex-1">
38
+ <span className="flex items-center gap-1.5 text-og-control font-medium text-og-fg">
39
+ {interventionTitle(intervention.kind)}
40
+ {interventions.length > 1 ? (
41
+ <span className="rounded-full bg-og-surface-2 px-1.5 py-0.5 text-[10px] font-normal text-og-muted">
42
+ +{interventions.length - 1}
43
+ </span>
44
+ ) : null}
45
+ </span>
46
+ <span className="block truncate text-og-xs text-og-muted">{intervention.reason}</span>
47
+ </span>
48
+ {!targetOpen && onOpen ? (
49
+ <button
50
+ type="button"
51
+ onClick={() => onOpen(intervention)}
52
+ disabled={mutating}
53
+ className="h-7 shrink-0 rounded-og-sm border border-og-border bg-og-surface-1 px-2.5 text-og-control font-medium text-og-fg transition hover:bg-og-surface-2 disabled:opacity-50"
54
+ >
55
+ Open
56
+ </button>
57
+ ) : (
58
+ <button
59
+ type="button"
60
+ onClick={() => onResolve(intervention, "completed")}
61
+ disabled={mutating}
62
+ className="inline-flex h-7 shrink-0 items-center gap-1 rounded-og-sm bg-og-accent px-2.5 text-og-control font-medium text-og-accent-fg transition hover:brightness-105 disabled:opacity-50"
63
+ >
64
+ {mutating ? (
65
+ <LoaderCircleIcon className="size-3 animate-spin" />
66
+ ) : (
67
+ <CheckIcon className="size-3" />
68
+ )}
69
+ Done
70
+ </button>
71
+ )}
72
+ <button
73
+ type="button"
74
+ onClick={() => onResolve(intervention, "dismissed")}
75
+ disabled={mutating}
76
+ className="grid size-7 shrink-0 place-items-center rounded-og-sm text-og-muted transition hover:bg-og-surface-2 hover:text-og-fg disabled:opacity-50"
77
+ aria-label="Cancel request"
78
+ title="Cancel request"
79
+ >
80
+ <XIcon className="size-3.5" />
81
+ </button>
82
+ </div>
83
+ );
84
+ }
85
+
86
+ function interventionTitle(kind: InteractionIntervention["kind"]): string {
87
+ switch (kind) {
88
+ case "manual_login":
89
+ return "Sign in needed";
90
+ case "mfa":
91
+ return "Verification needed";
92
+ case "external_action":
93
+ return "Action needed";
94
+ case "confirmation":
95
+ return "Confirmation needed";
96
+ case "other":
97
+ return "Agent needs your help";
98
+ }
99
+ }
@@ -95,7 +95,7 @@ export function MachineDetail({
95
95
  data-remove-machine
96
96
  aria-label={`Remove machine ${machine.name}`}
97
97
  onClick={() => onRemove(machine)}
98
- className="inline-flex min-h-9 items-center gap-1.5 rounded-og-sm border border-og-status-failed/40 px-2.5 py-1 font-medium text-og-xs text-og-status-failed transition-colors hover:bg-og-status-failed/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-status-failed/50 pointer-coarse:min-h-11"
98
+ className="inline-flex min-h-9 items-center gap-1.5 rounded-og-sm border border-og-status-failed/40 px-2.5 py-1 font-medium text-og-xs text-og-status-failed transition-colors hover:bg-og-status-failed/10 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-status-failed/50 pointer-coarse:min-h-11"
99
99
  >
100
100
  <Trash2Icon className="size-3.5" aria-hidden />
101
101
  <span className="hidden sm:inline">Remove machine</span>
@@ -65,6 +65,7 @@ export const defaultModelPolicyPickerMessages: ModelPolicyPickerMessages = {
65
65
  billingHints: {
66
66
  opengeni_credits: "Will use credits",
67
67
  codex_subscription: "ChatGPT / Codex plan",
68
+ supergrok_subscription: "SuperGrok / xAI plan",
68
69
  byok: "Billed to your AI Gateway",
69
70
  },
70
71
  };
@@ -132,6 +133,17 @@ function ChatGptMark(props: SVGProps<SVGSVGElement>) {
132
133
  );
133
134
  }
134
135
 
136
+ function XaiMark(props: SVGProps<SVGSVGElement>) {
137
+ return (
138
+ <svg viewBox="0 0 466.04 516.93" fill="currentColor" aria-hidden="true" {...props}>
139
+ <polygon points="0.12 182.71 234.14 516.92 338.15 516.92 104.13 182.71 0.12 182.71" />
140
+ <polygon points="0 516.92 104.08 516.92 156.08 442.67 104.04 368.34 0 516.92" />
141
+ <polygon points="466.04 0 361.96 0 182.1 256.86 234.15 331.18 466.04 0" />
142
+ <polygon points="380.78 516.92 466.04 516.92 466.04 37.16 380.78 158.92 380.78 516.92" />
143
+ </svg>
144
+ );
145
+ }
146
+
135
147
  export function BillingClassMark(props: {
136
148
  billingClass: PickerBillingClass;
137
149
  className?: string | undefined;
@@ -140,6 +152,7 @@ export function BillingClassMark(props: {
140
152
  const labels: Record<PickerBillingClass, string> = {
141
153
  opengeni_credits: "OpenGeni",
142
154
  codex_subscription: "Codex",
155
+ supergrok_subscription: "SuperGrok",
143
156
  byok: "Bring your own key",
144
157
  };
145
158
  const label = props["aria-label"] ?? labels[props.billingClass];
@@ -162,6 +175,8 @@ export function BillingClassMark(props: {
162
175
  <OpenGeniMark className={mark} />
163
176
  ) : props.billingClass === "codex_subscription" ? (
164
177
  <ChatGptMark className={mark} />
178
+ ) : props.billingClass === "supergrok_subscription" ? (
179
+ <XaiMark className={mark} />
165
180
  ) : (
166
181
  <KeyRoundIcon className={mark} aria-hidden />
167
182
  )}
@@ -1,5 +1,5 @@
1
1
  import type { TerminalCapability } from "@opengeni/sdk";
2
- import { type ReactNode, useEffect, useRef, useState } from "react";
2
+ import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
3
3
  import { cn } from "../lib/cn";
4
4
  import { type TerminalStreamStatus, useTerminalStream } from "../hooks/use-terminal-stream";
5
5
  import type { UseSandboxTerminalResult } from "../hooks/use-sandbox-terminal";
@@ -87,6 +87,8 @@ export type SandboxTerminalProps = {
87
87
  * the firehose-only default).
88
88
  */
89
89
  onActivate?: (() => void) | undefined;
90
+ /** Re-negotiate the terminal capability after an unexpected socket failure. */
91
+ onReconnectNeeded?: (() => void) | undefined;
90
92
  className?: string | undefined;
91
93
  };
92
94
 
@@ -94,6 +96,7 @@ export type SandboxTerminalProps = {
94
96
  // drive it without a hard type dep on the lazily-imported lib.
95
97
  type XtermLike = {
96
98
  open: (el: HTMLElement) => void;
99
+ focus: () => void;
97
100
  write: (data: string) => void;
98
101
  clear: () => void;
99
102
  onData: (cb: (data: string) => void) => { dispose: () => void };
@@ -223,6 +226,7 @@ export function SandboxTerminal({
223
226
  showHeader,
224
227
  shell,
225
228
  onActivate,
229
+ onReconnectNeeded,
226
230
  className,
227
231
  }: SandboxTerminalProps) {
228
232
  const containerRef = useRef<HTMLDivElement | null>(null);
@@ -237,7 +241,8 @@ export function SandboxTerminal({
237
241
  const [activated, setActivated] = useState(false);
238
242
 
239
243
  // ── Interactive transport switch ─────────────────────────────────────────────
240
- const ptyDescriptor = terminalCapability?.transport === "pty-ws";
244
+ const ptyDescriptor =
245
+ terminalCapability?.transport === "pty-ws" || terminalCapability?.transport === "relay-pty";
241
246
  const ptyAttachable = terminalCanAcquirePty(terminalCapability);
242
247
  const {
243
248
  status: ptyStatus,
@@ -251,7 +256,7 @@ export function SandboxTerminal({
251
256
  capability:
252
257
  ptyAttachable && ready
253
258
  ? {
254
- transport: "pty-ws",
259
+ transport: terminalCapability!.transport,
255
260
  url: ptyDescriptor ? terminalCapability!.url : null,
256
261
  token: ptyDescriptor ? terminalCapability!.token : null,
257
262
  expiresAt: ptyDescriptor ? terminalCapability!.expiresAt : null,
@@ -260,6 +265,7 @@ export function SandboxTerminal({
260
265
  onOutput: (data) => {
261
266
  termRef.current?.write(data);
262
267
  },
268
+ onReconnectNeeded,
263
269
  });
264
270
 
265
271
  // A pty-ws capability exclusively owns the screen even while connecting or
@@ -304,6 +310,16 @@ export function SandboxTerminal({
304
310
  });
305
311
 
306
312
  function handleActivate() {
313
+ // Capture-phase activation may synchronously negotiate a fresh terminal
314
+ // capability and rerender the shell. Focus xterm before that state change so
315
+ // the user's first keystroke or paste reaches its already-bounded input
316
+ // queue instead of landing on the surrounding dock.
317
+ const term = termRef.current;
318
+ if (term && !readOnly && (ptyAttachable || result.write !== null)) {
319
+ term.options.disableStdin = false;
320
+ term.options.cursorBlink = true;
321
+ }
322
+ term?.focus();
307
323
  if (!activated) setActivated(true);
308
324
  onActivate?.();
309
325
  }
@@ -451,7 +467,7 @@ export function SandboxTerminal({
451
467
 
452
468
  // Sync stdin/cursor at runtime (NOT a remount) when interactivity flips, so the
453
469
  // projection→PTY handoff keeps the single Terminal instance + its scrollback.
454
- useEffect(() => {
470
+ useLayoutEffect(() => {
455
471
  const term = termRef.current;
456
472
  if (!ready || !term) return;
457
473
  term.options.disableStdin = !interactive;
@@ -509,9 +525,10 @@ export function SandboxTerminal({
509
525
  }
510
526
  }, [ready, result.chunks, ptyMode]);
511
527
 
512
- // Wire interactive input when allowed. PTY mode pipes keystrokes to the ttyd
513
- // socket; firehose mode uses the legacy ptyWrite-over-HTTP fn.
514
- useEffect(() => {
528
+ // Wire interactive input when allowed. `ptyWrite` is stable across socket
529
+ // status changes, so this subscription does not tear down while a grant opens
530
+ // or rotates and cannot create a first-keystroke gap.
531
+ useLayoutEffect(() => {
515
532
  const term = termRef.current;
516
533
  if (!ready || !term || !interactive) {
517
534
  setInputReady(false);
@@ -25,6 +25,7 @@ import {
25
25
  useState,
26
26
  } from "react";
27
27
  import type { SessionEvent } from "@opengeni/sdk";
28
+ import type { InteractionPlacement } from "@opengeni/sdk/interaction";
28
29
  import {
29
30
  CircleCheckIcon,
30
31
  CpuIcon,
@@ -245,6 +246,9 @@ export type UseSandboxWorkspaceTabsOptions = ClientOverride & {
245
246
  initialTab?: string | null | undefined;
246
247
  /** Actually selected dock tab. Inactive data surfaces retain cache but issue no live reads. */
247
248
  activeTab?: string | null | undefined;
249
+ /** Whether the dock is visible. Hidden docks retain UI state but must not keep
250
+ * expensive browser/computer media streams alive. */
251
+ workspaceVisible?: boolean | undefined;
248
252
  /** Host-routed notifications (mutation errors, desktop-consent failures). The
249
253
  * package never imports a toast library — the host decides how to surface. */
250
254
  onNotify?: ((notification: WorkspaceNotification) => void) | undefined;
@@ -301,7 +305,7 @@ function emptyWarmIntents(sessionId: string): SessionWarmIntents {
301
305
  export function useSandboxWorkspaceTabs(
302
306
  options: UseSandboxWorkspaceTabsOptions,
303
307
  ): UseSandboxWorkspaceTabsResult {
304
- const { client, workspaceId } = useOpenGeni(options);
308
+ const { client, workspaceId, workspaceInteractionEvent } = useOpenGeni(options);
305
309
  const {
306
310
  sessionId,
307
311
  events,
@@ -317,6 +321,7 @@ export function useSandboxWorkspaceTabs(
317
321
  } = options;
318
322
  const initialTab = options.initialTab ?? null;
319
323
  const activeTab = options.activeTab ?? initialTab;
324
+ const workspaceVisible = options.workspaceVisible ?? true;
320
325
  const requestedSurfaces = options.surfaces ?? WORKBENCH_SURFACES;
321
326
  const surfaceSet = new Set<SandboxWorkspaceSurface>(requestedSurfaces);
322
327
  const changesEnabled = surfaceSet.has(WORKBENCH_TAB_CHANGES);
@@ -325,12 +330,16 @@ export function useSandboxWorkspaceTabs(
325
330
  const browserEnabled = surfaceSet.has(WORKBENCH_TAB_BROWSER);
326
331
  const desktopEnabled = surfaceSet.has(WORKBENCH_TAB_DESKTOP);
327
332
  const createLinkedComputer = useCallback(
328
- async (name: string) => {
333
+ async (name: string, placement?: InteractionPlacement) => {
329
334
  const response = await client.createComputerSession(workspaceId, {
330
335
  operationId: crypto.randomUUID(),
331
336
  sessionId,
332
337
  name,
338
+ ...(placement ? { placement } : {}),
333
339
  });
340
+ if (response.operation.state !== "completed" || response.session.lifecycle !== "active") {
341
+ throw new Error(response.operation.error?.message ?? "The computer could not be opened.");
342
+ }
334
343
  return {
335
344
  id: response.session.id,
336
345
  placement: response.session.placement,
@@ -444,6 +453,17 @@ export function useSandboxWorkspaceTabs(
444
453
  provisioningRef.current = { sessionId, provisioning };
445
454
  }, [sessionId, provisioning, renegotiate]);
446
455
 
456
+ // BrowserSession and ComputerSession lifecycles use the same placement lease
457
+ // as files/terminal/desktop, but are workspace resources rather than session
458
+ // events. Re-read the capability document on their canonical workspace
459
+ // invalidation so the one machine chip cannot remain "Offline" beside a live
460
+ // browser/computer (or remain "Live" after the final interaction holder ends).
461
+ // This is a read-only negotiation: it never acquires a holder or wakes a box.
462
+ useEffect(() => {
463
+ if (workspaceInteractionEvent?.workspaceId !== workspaceId) return;
464
+ renegotiate();
465
+ }, [workspaceId, workspaceInteractionEvent, renegotiate]);
466
+
447
467
  // The cold-paint data source: the latest turn-end capture, fetched with a single
448
468
  // api round-trip on mount (no machine). Feeds the Files tree + the Changes/Git
449
469
  // diff immediately; a warm box reconciles live without discarding the capture
@@ -748,6 +768,7 @@ export function useSandboxWorkspaceTabs(
748
768
  result={terminal}
749
769
  terminalCapability={capabilities?.Terminal ?? null}
750
770
  onActivate={() => requestWarmIntent("warmTerminal")}
771
+ onReconnectNeeded={caps.renegotiate}
751
772
  showHeader
752
773
  shell={capabilities?.Terminal.shell ?? undefined}
753
774
  liveness={liveness}
@@ -772,6 +793,15 @@ export function useSandboxWorkspaceTabs(
772
793
  client={client}
773
794
  workspaceId={workspaceId}
774
795
  sessionId={sessionId}
796
+ // WorkspaceDock mounts a heavy surface only after its first visit,
797
+ // then keeps it mounted behind `hidden` when another tab is
798
+ // selected. Keep that already-visited surface enabled while the
799
+ // dock itself remains open: tearing down its controller + media
800
+ // socket on every Browser↔Computer tab switch turned an ordinary
801
+ // click into a multi-second cold reconnect. This does not eagerly
802
+ // start BrowserViewer—the lazy panel is still unmounted until the
803
+ // user/agent first visits it.
804
+ enabled={workspaceVisible}
775
805
  onNotify={onNotify}
776
806
  {...(desktopEnabled ? { createLinkedComputer } : {})}
777
807
  {...(onOpenComputerSession ? { onOpenComputer: onOpenComputerSession } : {})}
@@ -796,6 +826,11 @@ export function useSandboxWorkspaceTabs(
796
826
  client={client}
797
827
  workspaceId={workspaceId}
798
828
  sessionId={sessionId}
829
+ // Same visited-surface lifetime as Browser above. A live noVNC
830
+ // connection survives tab switches and ResizeObserver re-fits its
831
+ // canvas when this hidden panel becomes visible again, making the
832
+ // second and later Computer opens local-machine fast.
833
+ enabled={workspaceVisible}
799
834
  onNotify={onNotify}
800
835
  requestedComputerSessionId={requestedComputerSessionId}
801
836
  requestedComputerRequestId={requestedComputerRequestId}
@@ -828,6 +863,8 @@ export function useSandboxWorkspaceTabs(
828
863
  onNotify,
829
864
  browserWebSocketFactory,
830
865
  computerWebSocketFactory,
866
+ workspaceVisible,
867
+ resolvedActiveTab,
831
868
  client,
832
869
  workspaceId,
833
870
  liveness,
@@ -979,6 +1016,7 @@ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
979
1016
  ...(surfaces ? { surfaces } : {}),
980
1017
  ...(initialTab ? { initialTab } : {}),
981
1018
  activeTab: activeTabHint,
1019
+ workspaceVisible: collapsed !== true,
982
1020
  ...(onNotify ? { onNotify } : {}),
983
1021
  ...(browserWebSocketFactory ? { browserWebSocketFactory } : {}),
984
1022
  ...(computerWebSocketFactory ? { computerWebSocketFactory } : {}),
@@ -1057,13 +1095,13 @@ function MachineStateChip({ chip }: { chip: MachineChip }) {
1057
1095
  <div
1058
1096
  role="status"
1059
1097
  aria-label={`Machine: ${chip.label}`}
1060
- className="inline-flex min-h-7 items-center gap-1.5 px-2 py-1 text-og-xs font-medium text-og-fg-muted max-[1023px]:min-h-11 pointer-coarse:min-h-11"
1098
+ className="inline-flex min-h-7 min-w-0 items-center gap-1.5 px-2 py-1 text-og-xs font-medium text-og-fg-muted max-[1023px]:min-h-11 pointer-coarse:min-h-11"
1061
1099
  >
1062
1100
  <span
1063
1101
  className={cn("size-1.5 shrink-0 rounded-full", chipDotClass(chip.state))}
1064
1102
  aria-hidden
1065
1103
  />
1066
- <span className="max-w-[11rem] truncate">{chip.label}</span>
1104
+ <span className="min-w-0 max-w-[11rem] truncate">{chip.label}</span>
1067
1105
  </div>
1068
1106
  );
1069
1107
  }
@@ -536,6 +536,8 @@ function DockChrome({
536
536
  /** Right-aligned chrome controls (maximize / collapse, or the overlay close). */
537
537
  controls: ReactNode;
538
538
  }) {
539
+ const chromeRef = useRef<HTMLDivElement | null>(null);
540
+ const [compact, setCompact] = useState(false);
539
541
  const active = tabs.find((t) => t.id === current) ?? tabs[0];
540
542
  const tabsetId = useId();
541
543
  const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
@@ -544,6 +546,31 @@ function DockChrome({
544
546
  const [tabOverflow, setTabOverflow] = useState({ start: false, end: false });
545
547
  const activeId = active?.id ?? "";
546
548
  const activeTabIndex = tabs.findIndex((tab) => tab.id === activeId);
549
+
550
+ // A desktop dock can be much narrower than the viewport after the user drags
551
+ // its separator. Viewport breakpoints therefore cannot protect the tab strip:
552
+ // the status + controls used to squeeze it to zero width, leaving visible but
553
+ // unclickable ARIA tabs. At narrow *dock* widths, give status/controls their
554
+ // own row and the horizontally scrollable tabs the complete second row.
555
+ useDockLayoutEffect(() => {
556
+ const node = chromeRef.current;
557
+ if (!node) return;
558
+ const update = () => {
559
+ const desktop =
560
+ typeof window.matchMedia !== "function" || window.matchMedia("(min-width: 1024px)").matches;
561
+ const next = desktop && node.getBoundingClientRect().width < 420;
562
+ setCompact((previous) => (previous === next ? previous : next));
563
+ };
564
+ update();
565
+ if (typeof ResizeObserver === "undefined") {
566
+ window.addEventListener("resize", update);
567
+ return () => window.removeEventListener("resize", update);
568
+ }
569
+ const observer = new ResizeObserver(update);
570
+ observer.observe(node);
571
+ return () => observer.disconnect();
572
+ }, []);
573
+
547
574
  const keepActiveTabVisible = useCallback(() => {
548
575
  const selected = activeTabIndex >= 0 ? tabRefs.current[activeTabIndex] : null;
549
576
  selected?.scrollIntoView({ block: "nearest", inline: "nearest" });
@@ -631,8 +658,22 @@ function DockChrome({
631
658
 
632
659
  return (
633
660
  <>
634
- <div className="grid shrink-0 grid-cols-[auto_minmax(0,1fr)_auto_auto] items-center gap-x-2 border-b border-og-border px-1.5 py-1 max-[1023px]:grid-cols-[auto_minmax(0,1fr)_auto] max-[1023px]:gap-y-0 max-[1023px]:px-2 max-[1023px]:pb-1 max-[1023px]:pt-0 min-[640px]:max-[1023px]:grid-cols-[auto_minmax(0,1fr)_auto_auto]">
635
- <div className="flex min-w-0 shrink-0 items-center max-[1023px]:min-h-11 min-[640px]:max-[1023px]:col-start-1 min-[640px]:max-[1023px]:row-start-1">
661
+ <div
662
+ ref={chromeRef}
663
+ data-dock-chrome
664
+ data-compact={compact ? "true" : undefined}
665
+ className={cn(
666
+ "grid shrink-0 grid-cols-[auto_minmax(0,1fr)_auto_auto] items-center gap-x-2 border-b border-og-border px-1.5 py-1 max-[1023px]:grid-cols-[auto_minmax(0,1fr)_auto] max-[1023px]:gap-y-0 max-[1023px]:px-2 max-[1023px]:pb-1 max-[1023px]:pt-0 min-[640px]:max-[1023px]:grid-cols-[auto_minmax(0,1fr)_auto_auto]",
667
+ compact && "gap-y-1",
668
+ )}
669
+ style={compact ? { gridTemplateColumns: "minmax(0, 1fr) auto" } : undefined}
670
+ >
671
+ <div
672
+ className={cn(
673
+ "flex min-w-0 shrink-0 items-center max-[1023px]:min-h-11 min-[640px]:max-[1023px]:col-start-1 min-[640px]:max-[1023px]:row-start-1",
674
+ compact && "hidden",
675
+ )}
676
+ >
636
677
  {leading ?? (
637
678
  <span className="hidden truncate px-1 text-og-sm font-semibold text-og-fg max-[1023px]:inline">
638
679
  Workspace
@@ -648,7 +689,10 @@ function DockChrome({
648
689
  absorbing any unusually long host-injected labels. */}
649
690
  <div
650
691
  ref={tabListRef}
651
- className="flex min-w-0 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden max-[1023px]:col-span-3 max-[1023px]:row-start-2 max-[1023px]:w-full min-[640px]:max-[1023px]:col-span-1 min-[640px]:max-[1023px]:col-start-2 min-[640px]:max-[1023px]:row-start-1"
692
+ className={cn(
693
+ "flex min-w-0 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden max-[1023px]:col-span-3 max-[1023px]:row-start-2 max-[1023px]:w-full min-[640px]:max-[1023px]:col-span-1 min-[640px]:max-[1023px]:col-start-2 min-[640px]:max-[1023px]:row-start-1",
694
+ compact && "col-span-2 col-start-1 row-start-2 w-full",
695
+ )}
652
696
  role="tablist"
653
697
  aria-orientation="horizontal"
654
698
  style={{
@@ -683,10 +727,20 @@ function DockChrome({
683
727
  </button>
684
728
  ))}
685
729
  </div>
686
- <div className="flex min-w-0 shrink-0 items-center justify-self-end min-[640px]:max-[1023px]:col-start-3 min-[640px]:max-[1023px]:row-start-1">
730
+ <div
731
+ className={cn(
732
+ "flex min-w-0 shrink-0 items-center justify-self-end min-[640px]:max-[1023px]:col-start-3 min-[640px]:max-[1023px]:row-start-1",
733
+ compact && "col-start-1 row-start-1 justify-self-start",
734
+ )}
735
+ >
687
736
  {accessory}
688
737
  </div>
689
- <div className="flex shrink-0 items-center gap-0.5 text-og-fg-subtle min-[640px]:max-[1023px]:col-start-4 min-[640px]:max-[1023px]:row-start-1">
738
+ <div
739
+ className={cn(
740
+ "flex shrink-0 items-center gap-0.5 text-og-fg-subtle min-[640px]:max-[1023px]:col-start-4 min-[640px]:max-[1023px]:row-start-1",
741
+ compact && "col-start-2 row-start-1",
742
+ )}
743
+ >
690
744
  {controls}
691
745
  </div>
692
746
  </div>