@opengeni/react 1.0.1 → 2.2.0-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/README.md +33 -10
  2. package/dist/artifacts-spreadsheet.js +1 -1
  3. package/dist/artifacts.js +1 -1
  4. package/dist/browser-viewer-GXUICIAG.js +14 -0
  5. package/dist/{chunk-R3AZOAB5.js → chunk-4MDLEHXL.js} +9 -115
  6. package/dist/chunk-4MDLEHXL.js.map +1 -0
  7. package/dist/{chunk-Y7OEDUJ5.js → chunk-5RZN3DD5.js} +1014 -420
  8. package/dist/chunk-5RZN3DD5.js.map +1 -0
  9. package/dist/chunk-5YK2ZDIG.js +116 -0
  10. package/dist/chunk-5YK2ZDIG.js.map +1 -0
  11. package/dist/chunk-H5YGYNXS.js +329 -0
  12. package/dist/chunk-H5YGYNXS.js.map +1 -0
  13. package/dist/{chunk-7WCLAEFV.js → chunk-ICA6R6TW.js} +2 -2
  14. package/dist/{chunk-7WCLAEFV.js.map → chunk-ICA6R6TW.js.map} +1 -1
  15. package/dist/{chunk-IGC6A2ZI.js → chunk-JOAVA2TW.js} +589 -217
  16. package/dist/chunk-JOAVA2TW.js.map +1 -0
  17. package/dist/{chunk-5UQPNLIO.js → chunk-LTCHJ4MS.js} +9 -9
  18. package/dist/chunk-LTCHJ4MS.js.map +1 -0
  19. package/dist/{chunk-C5ZPSSS5.js → chunk-NEXLLTP4.js} +2 -1
  20. package/dist/{chunk-C5ZPSSS5.js.map → chunk-NEXLLTP4.js.map} +1 -1
  21. package/dist/chunk-QGBJ6GHG.js +2300 -0
  22. package/dist/chunk-QGBJ6GHG.js.map +1 -0
  23. package/dist/{chunk-B6WPZSTT.js → chunk-UOLJWT5Y.js} +78 -16
  24. package/dist/chunk-UOLJWT5Y.js.map +1 -0
  25. package/dist/{chunk-MKPIAGVL.js → chunk-W6NFJXSA.js} +8 -19
  26. package/dist/chunk-W6NFJXSA.js.map +1 -0
  27. package/dist/{chunk-XBZWOM2K.js → chunk-X4A66EWZ.js} +8 -3
  28. package/dist/chunk-X4A66EWZ.js.map +1 -0
  29. package/dist/chunk-X4JAFD5R.js +3907 -0
  30. package/dist/chunk-X4JAFD5R.js.map +1 -0
  31. package/dist/{chunk-6KLX2RH5.js → chunk-XSA2QAKP.js} +334 -19
  32. package/dist/chunk-XSA2QAKP.js.map +1 -0
  33. package/dist/{chunk-HA46CYQK.js → chunk-XXWH2JLC.js} +792 -206
  34. package/dist/chunk-XXWH2JLC.js.map +1 -0
  35. package/dist/client.d.ts +2 -2
  36. package/dist/components/browser-viewer.d.ts +7 -1
  37. package/dist/components/computer-viewer.d.ts +4 -1
  38. package/dist/components/enrollment-consent.d.ts +6 -3
  39. package/dist/components/file-browser.d.ts +7 -1
  40. package/dist/components/human-input-form.d.ts +1 -1
  41. package/dist/components/human-input-surface.d.ts +1 -1
  42. package/dist/components/machine-card.d.ts +4 -1
  43. package/dist/components/machines/machine-detail.d.ts +4 -1
  44. package/dist/components/machines/machine-operation-policy.d.ts +11 -0
  45. package/dist/components/machines-dashboard.d.ts +3 -1
  46. package/dist/components/markdown.d.ts +20 -1
  47. package/dist/components/message-timeline.d.ts +1 -1
  48. package/dist/components/model-policy-picker.d.ts +2 -0
  49. package/dist/components/pierre-file.d.ts +1 -1
  50. package/dist/components/sandbox-files.d.ts +8 -2
  51. package/dist/components/sandbox-workspace.d.ts +33 -3
  52. package/dist/components/session-chrome.d.ts +15 -3
  53. package/dist/components/workspace-dock.d.ts +6 -2
  54. package/dist/composer.js +5 -5
  55. package/dist/computer-viewer-ZOP4Q7A7.js +16 -0
  56. package/dist/computer-viewer-ZOP4Q7A7.js.map +1 -0
  57. package/dist/hooks/channel-cache.d.ts +23 -0
  58. package/dist/hooks/shared-machines-list.d.ts +14 -0
  59. package/dist/hooks/use-attached-browsers.d.ts +2 -1
  60. package/dist/hooks/use-channels.d.ts +1 -0
  61. package/dist/hooks/use-composer.d.ts +37 -9
  62. package/dist/hooks/use-machine-chip.d.ts +3 -3
  63. package/dist/hooks/use-machines.d.ts +19 -1
  64. package/dist/hooks/use-sandbox-files.d.ts +1 -0
  65. package/dist/hooks/use-session-background-commands.d.ts +14 -0
  66. package/dist/hooks/use-turn-queue.d.ts +12 -0
  67. package/dist/hooks/use-workspace-sessions.d.ts +1 -0
  68. package/dist/index.d.ts +7 -5
  69. package/dist/index.js +1096 -623
  70. package/dist/index.js.map +1 -1
  71. package/dist/interaction.js +29 -181
  72. package/dist/interaction.js.map +1 -1
  73. package/dist/lib/interaction-errors.d.ts +3 -0
  74. package/dist/machines.d.ts +4 -2
  75. package/dist/machines.js +10 -2
  76. package/dist/model-policy.d.ts +1 -1
  77. package/dist/model-policy.js +1 -1
  78. package/dist/platform-activity-row-BJHUHXAT.js +169 -0
  79. package/dist/platform-activity-row-BJHUHXAT.js.map +1 -0
  80. package/dist/{queue-surface-implementation-2KVJTYXH.js → queue-surface-implementation-BSKUWHRI.js} +76 -16
  81. package/dist/queue-surface-implementation-BSKUWHRI.js.map +1 -0
  82. package/dist/realtime/realtime-control.d.ts +6 -3
  83. package/dist/realtime.js +36 -14
  84. package/dist/realtime.js.map +1 -1
  85. package/dist/session-ui.d.ts +1 -1
  86. package/dist/session-ui.js +6 -2
  87. package/dist/session.d.ts +2 -2
  88. package/dist/session.js +4 -4
  89. package/dist/timeline/index.d.ts +1 -1
  90. package/dist/timeline/platform-activity-row.d.ts +15 -0
  91. package/dist/timeline/screenshot-lightbox.d.ts +1 -1
  92. package/dist/timeline/shared.d.ts +4 -2
  93. package/dist/timeline/types.d.ts +48 -5
  94. package/dist/types/machines.d.ts +2 -2
  95. package/package.json +3 -3
  96. package/src/client.ts +12 -1
  97. package/src/components/artifacts/editable-spreadsheet.tsx +1 -1
  98. package/src/components/browser-viewer.tsx +361 -58
  99. package/src/components/chat-composer.tsx +1 -1
  100. package/src/components/composer.tsx +6 -6
  101. package/src/components/computer-viewer.tsx +368 -115
  102. package/src/components/enrollment-consent.tsx +64 -6
  103. package/src/components/file-browser.tsx +117 -2
  104. package/src/components/human-input-form.tsx +91 -55
  105. package/src/components/human-input-surface.tsx +1 -1
  106. package/src/components/machine-card.tsx +111 -0
  107. package/src/components/machine-input-display.ts +15 -0
  108. package/src/components/machines/machine-detail.tsx +47 -0
  109. package/src/components/machines/machine-operation-policy.tsx +179 -0
  110. package/src/components/machines-dashboard.tsx +6 -0
  111. package/src/components/markdown.tsx +174 -20
  112. package/src/components/message-timeline.tsx +162 -17
  113. package/src/components/model-policy-picker.tsx +9 -17
  114. package/src/components/pierre-file.tsx +1 -1
  115. package/src/components/queue-surface-implementation.tsx +82 -16
  116. package/src/components/sandbox-files.tsx +135 -19
  117. package/src/components/sandbox-workspace.tsx +204 -92
  118. package/src/components/session-chrome.tsx +415 -208
  119. package/src/components/session-status.tsx +1 -1
  120. package/src/components/user-message-body.tsx +168 -48
  121. package/src/components/workspace-dock.tsx +141 -175
  122. package/src/hooks/channel-cache.ts +104 -0
  123. package/src/hooks/shared-machines-list.ts +239 -0
  124. package/src/hooks/use-attached-browsers.ts +5 -1
  125. package/src/hooks/use-browser-frame-stream.ts +6 -1
  126. package/src/hooks/use-browser-session.ts +6 -4
  127. package/src/hooks/use-browser-sessions.ts +7 -1
  128. package/src/hooks/use-channels.ts +32 -10
  129. package/src/hooks/use-composer.ts +760 -291
  130. package/src/hooks/use-computer-frame-stream.ts +14 -9
  131. package/src/hooks/use-computer-session.ts +16 -14
  132. package/src/hooks/use-computer-sessions.ts +8 -1
  133. package/src/hooks/use-last-started-turn-policy.ts +1 -1
  134. package/src/hooks/use-machine-chip.ts +23 -6
  135. package/src/hooks/use-machines.ts +104 -6
  136. package/src/hooks/use-sandbox-files.ts +195 -140
  137. package/src/hooks/use-sandbox-git.ts +58 -29
  138. package/src/hooks/use-session-background-commands.ts +56 -0
  139. package/src/hooks/use-session-capabilities.ts +18 -0
  140. package/src/hooks/use-turn-queue.ts +111 -12
  141. package/src/hooks/use-workspace-sessions.ts +5 -1
  142. package/src/index.ts +26 -4
  143. package/src/lib/interaction-errors.ts +18 -0
  144. package/src/machines.ts +9 -1
  145. package/src/model-policy.ts +10 -2
  146. package/src/realtime/realtime-control.tsx +53 -17
  147. package/src/session-context.ts +1 -0
  148. package/src/session-ui.ts +6 -1
  149. package/src/session.ts +9 -1
  150. package/src/timeline/activity-rail.tsx +20 -75
  151. package/src/timeline/index.ts +4 -0
  152. package/src/timeline/platform-activity-row.tsx +201 -0
  153. package/src/timeline/projection.ts +476 -23
  154. package/src/timeline/screenshot-lightbox.tsx +48 -14
  155. package/src/timeline/shared.tsx +8 -2
  156. package/src/timeline/tool-renderers.tsx +104 -6
  157. package/src/timeline/turn-summary.tsx +6 -1
  158. package/src/timeline/types.ts +75 -2
  159. package/src/types/machines.ts +10 -0
  160. package/styles/compiled.css +115 -104
  161. package/dist/chunk-5UQPNLIO.js.map +0 -1
  162. package/dist/chunk-6KLX2RH5.js.map +0 -1
  163. package/dist/chunk-B6WPZSTT.js.map +0 -1
  164. package/dist/chunk-HA46CYQK.js.map +0 -1
  165. package/dist/chunk-IGC6A2ZI.js.map +0 -1
  166. package/dist/chunk-LLOLUHVG.js +0 -5937
  167. package/dist/chunk-LLOLUHVG.js.map +0 -1
  168. package/dist/chunk-MKPIAGVL.js.map +0 -1
  169. package/dist/chunk-R3AZOAB5.js.map +0 -1
  170. package/dist/chunk-XBZWOM2K.js.map +0 -1
  171. package/dist/chunk-Y7OEDUJ5.js.map +0 -1
  172. package/dist/interactive-workbench-viewers-TOHBXJEE.js +0 -15
  173. package/dist/queue-surface-implementation-2KVJTYXH.js.map +0 -1
  174. /package/dist/{interactive-workbench-viewers-TOHBXJEE.js.map → browser-viewer-GXUICIAG.js.map} +0 -0
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ AttachedBrowserBridge,
2
3
  AttachedBrowserDevice,
3
4
  BrowserAction,
4
5
  BrowserActionReceipt,
@@ -15,7 +16,9 @@ import type {
15
16
  InteractionPlacement,
16
17
  InteractionIntervention,
17
18
  InteractionSemanticNode,
19
+ SiteAuthConnection,
18
20
  } from "@opengeni/sdk/interaction";
21
+ import { interactionControlFailureFromError } from "@opengeni/sdk/interaction";
19
22
  import {
20
23
  BugIcon,
21
24
  ArchiveIcon,
@@ -61,9 +64,11 @@ import { useBrowserDownloads } from "../hooks/use-browser-downloads";
61
64
  import { useBrowserSession } from "../hooks/use-browser-session";
62
65
  import { useBrowserSessions } from "../hooks/use-browser-sessions";
63
66
  import { useInteractionInterventions } from "../hooks/use-interaction-interventions";
67
+ import { useSiteAuthConnections } from "../hooks/use-site-auth-connections";
64
68
  import { cn } from "../lib/cn";
65
69
  import { copyTextToClipboard } from "../lib/clipboard";
66
70
  import { formatBytes } from "../lib/format";
71
+ import { isSourcePlacementChangedError } from "../lib/interaction-errors";
67
72
  import type { EmbeddedBrowserInteractionClientOverride } from "../session-context";
68
73
  import { browserKey, HUMAN_BROWSER_HOME_URL, normalizeBrowserAddress } from "./browser-input";
69
74
  import { InteractionInterventionBanner } from "./interaction-intervention-banner";
@@ -93,12 +98,18 @@ export type BrowserViewerProps = EmbeddedBrowserInteractionClientOverride & {
93
98
  | undefined;
94
99
  /** Navigate to the exact linked ComputerSession; never a lookalike desktop. */
95
100
  onOpenComputer?: ((computerSessionId: string) => void) | undefined;
101
+ /** Restore and report the BrowserSession selected in this task's dock. */
102
+ initialBrowserSessionId?: string | null | undefined;
103
+ onBrowserSessionIdChange?: ((browserSessionId: string | null) => void) | undefined;
104
+ /** Host-owned install/setup surface for attaching an existing Chrome profile.
105
+ * The machine bridge remains discoverable even when this URL is omitted. */
106
+ browserExtensionSetupUrl?: string | undefined;
96
107
  };
97
108
 
98
109
  type BrowserSelection = { sessionId: string; pinned: boolean } | null;
99
110
  type BrowserLaunchChoice =
100
111
  | { kind: "clean" }
101
- | { kind: "profile"; identityId: string }
112
+ | { kind: "profile"; identityId: string; baseRevisionId?: string }
102
113
  | { kind: "attached"; device: AttachedBrowserDevice };
103
114
  type PointerStart = {
104
115
  x: number;
@@ -134,26 +145,72 @@ export function BrowserViewer({
134
145
  renderEmpty,
135
146
  createLinkedComputer,
136
147
  onOpenComputer,
148
+ initialBrowserSessionId,
149
+ onBrowserSessionIdChange,
150
+ browserExtensionSetupUrl,
137
151
  ...override
138
152
  }: BrowserViewerProps) {
139
153
  const registry = useBrowserSessions({ ...override, sessionId, enabled });
140
154
  const attached = useAttachedBrowsers({ ...override, enabled });
141
- const profiles = useBrowserIdentities({ ...override, enabled, includeArchived: true });
142
- const activeProfiles = useMemo(
143
- () => profiles.identities.filter((identity) => identity.status === "active"),
144
- [profiles.identities],
155
+ const onlineAttachedBridges = useMemo(
156
+ () => attached.bridges.filter((bridge) => bridge.state === "online"),
157
+ [attached.bridges],
145
158
  );
159
+ const profiles = useBrowserIdentities({ ...override, enabled, includeArchived: true });
160
+ const siteAuth = useSiteAuthConnections({
161
+ ...override,
162
+ enabled: enabled && profiles.identities.length > 0,
163
+ });
146
164
  const createRegistryBrowser = registry.create;
165
+ const refreshRegistry = registry.refresh;
147
166
  const loadProfileRevisions = profiles.revisions;
148
167
  const liveSessions = useMemo(
149
168
  () => registry.sessions.filter((session) => isLiveBrowser(session)),
150
169
  [registry.sessions],
151
170
  );
171
+ const attachedGenerationLoss = useMemo(
172
+ () => attachedChromeGenerationLoss(registry.sessions),
173
+ [registry.sessions],
174
+ );
175
+ const endedGenerationLossRef = useRef(new Set<string>());
176
+ const endStaleBrowser = registry.end;
177
+ useEffect(() => {
178
+ if (!enabled) return;
179
+ for (const session of registry.sessions) {
180
+ if (
181
+ session.lifecycle !== "lost" ||
182
+ session.failureCode !== "controller_transition_expired" ||
183
+ session.placement.kind !== "attached_device"
184
+ ) {
185
+ continue;
186
+ }
187
+ if (endedGenerationLossRef.current.has(session.id)) continue;
188
+ endedGenerationLossRef.current.add(session.id);
189
+ void endStaleBrowser(session.id).catch(() => {
190
+ endedGenerationLossRef.current.delete(session.id);
191
+ });
192
+ }
193
+ }, [enabled, endStaleBrowser, registry.sessions]);
194
+ const replacementChromeDevice = useMemo(() => {
195
+ if (!attachedGenerationLoss) return null;
196
+ return (
197
+ attached.devices.find((candidate) => candidate.id === attachedGenerationLoss.deviceId) ?? null
198
+ );
199
+ }, [attached.devices, attachedGenerationLoss]);
152
200
  const relevant = useMemo(
153
201
  () => registry.relevantSessions.filter((session) => isLiveBrowser(session)),
154
202
  [registry.relevantSessions],
155
203
  );
156
- const [selection, setSelection] = useState<BrowserSelection>(null);
204
+ const [selection, setSelection] = useState<BrowserSelection>(() =>
205
+ initialBrowserSessionId ? { sessionId: initialBrowserSessionId, pinned: true } : null,
206
+ );
207
+ const selectBrowserSession = useCallback(
208
+ (next: BrowserSelection) => {
209
+ setSelection(next);
210
+ onBrowserSessionIdChange?.(next?.sessionId ?? null);
211
+ },
212
+ [onBrowserSessionIdChange],
213
+ );
157
214
  const interventions = useInteractionInterventions({
158
215
  ...override,
159
216
  enabled,
@@ -188,11 +245,14 @@ export function BrowserViewer({
188
245
  useEffect(() => {
189
246
  if (previousSessionIdRef.current !== sessionId) {
190
247
  previousSessionIdRef.current = sessionId;
191
- setSelection(null);
248
+ setSelection(
249
+ initialBrowserSessionId ? { sessionId: initialBrowserSessionId, pinned: true } : null,
250
+ );
192
251
  }
193
- }, [sessionId]);
252
+ }, [initialBrowserSessionId, sessionId]);
194
253
 
195
254
  useEffect(() => {
255
+ if (!enabled || registry.loading) return;
196
256
  const selectedStillLive = liveSessions.some((session) => session.id === selection?.sessionId);
197
257
  // A manually selected workspace browser stays selected, but an unrelated
198
258
  // browser must never become the implicit browser for this agent. That made
@@ -200,15 +260,15 @@ export function BrowserViewer({
200
260
  if (selection?.pinned && selectedStillLive) return;
201
261
  const preferred = relevant[0] ?? null;
202
262
  if (!preferred) {
203
- if (selection) setSelection(null);
263
+ if (selection) selectBrowserSession(null);
204
264
  return;
205
265
  }
206
266
  if (!selectedStillLive || !selection?.pinned) {
207
267
  if (selection?.sessionId !== preferred.id || selection.pinned) {
208
- setSelection({ sessionId: preferred.id, pinned: false });
268
+ selectBrowserSession({ sessionId: preferred.id, pinned: false });
209
269
  }
210
270
  }
211
- }, [liveSessions, relevant, selection]);
271
+ }, [enabled, liveSessions, registry.loading, relevant, selectBrowserSession, selection]);
212
272
 
213
273
  const selectedRegistrySession = useMemo(
214
274
  () => liveSessions.find((session) => session.id === selection?.sessionId) ?? null,
@@ -289,6 +349,10 @@ export function BrowserViewer({
289
349
  browserSessionId: selection?.sessionId ?? null,
290
350
  enabled: enabled && selection !== null && controllerReady,
291
351
  });
352
+ useEffect(() => {
353
+ if (!isSourcePlacementChangedError(browser.error, "browser_session")) return;
354
+ void refreshRegistry();
355
+ }, [browser.error, refreshRegistry]);
292
356
  const downloads = useBrowserDownloads({
293
357
  ...override,
294
358
  browserSessionId: selection?.sessionId ?? null,
@@ -363,6 +427,15 @@ export function BrowserViewer({
363
427
  ) ?? null,
364
428
  [browser.session?.identityId, profiles.identities, selectedRegistrySession?.identityId],
365
429
  );
430
+ const selectedProfileAuth = useMemo(
431
+ () =>
432
+ selectedProfile
433
+ ? siteAuth.connections.filter(
434
+ (connection) => connection.preferredIdentityId === selectedProfile.id,
435
+ )
436
+ : [],
437
+ [selectedProfile, siteAuth.connections],
438
+ );
366
439
 
367
440
  useEffect(() => {
368
441
  diagnosticRequestRef.current += 1;
@@ -469,8 +542,15 @@ export function BrowserViewer({
469
542
  if (creating) return;
470
543
  const identity =
471
544
  choice.kind === "profile"
472
- ? activeProfiles.find((candidate) => candidate.id === choice.identityId)
545
+ ? profiles.identities.find((candidate) => candidate.id === choice.identityId)
473
546
  : null;
547
+ if (choice.kind === "profile" && (!identity || identity.status !== "active")) {
548
+ onNotify?.({
549
+ kind: "error",
550
+ message: "This browser profile is no longer available. Refresh and choose another.",
551
+ });
552
+ return;
553
+ }
474
554
  const device = choice.kind === "attached" ? choice.device : null;
475
555
  const browserName =
476
556
  device?.profileLabel ?? device?.name ?? (identity ? `${identity.name} browser` : "Browser");
@@ -481,7 +561,7 @@ export function BrowserViewer({
481
561
  if (createLinkedComputer) {
482
562
  try {
483
563
  linkedComputer = await createLinkedComputer(
484
- `${browserName} computer`,
564
+ `${browserName} desktop`,
485
565
  device ? { kind: "attached_device", deviceId: device.id } : undefined,
486
566
  );
487
567
  } catch {
@@ -514,13 +594,20 @@ export function BrowserViewer({
514
594
  placement: linkedComputer.placement,
515
595
  }
516
596
  : {}),
517
- ...(identity ? { identityId: identity.id } : {}),
597
+ ...(identity
598
+ ? {
599
+ identityId: identity.id,
600
+ ...(choice.kind === "profile" && choice.baseRevisionId
601
+ ? { baseRevisionId: choice.baseRevisionId }
602
+ : {}),
603
+ }
604
+ : {}),
518
605
  });
519
- setSelection({ sessionId: response.session.id, pinned: true });
606
+ selectBrowserSession({ sessionId: response.session.id, pinned: true });
520
607
  if (computerViewUnavailable) {
521
608
  onNotify?.({
522
609
  kind: "info",
523
- message: "Browser opened. Computer view is unavailable on this placement.",
610
+ message: "Browser opened. Desktop view is unavailable on this placement.",
524
611
  });
525
612
  }
526
613
  })()
@@ -533,7 +620,8 @@ export function BrowserViewer({
533
620
  creating,
534
621
  notifyError,
535
622
  onNotify,
536
- activeProfiles,
623
+ selectBrowserSession,
624
+ profiles.identities,
537
625
  sessionId,
538
626
  ],
539
627
  );
@@ -656,17 +744,25 @@ export function BrowserViewer({
656
744
  <span className="mx-auto grid size-10 place-items-center rounded-og-md border border-og-border bg-og-surface-1 text-og-muted">
657
745
  <Globe2Icon className="size-4.5" />
658
746
  </span>
659
- <p className="mt-3 text-og-menu font-medium text-og-fg">No browser open</p>
747
+ <p className="mt-3 text-og-menu font-medium text-og-fg">
748
+ {attachedGenerationLoss
749
+ ? "Chrome reconnected—open a fresh browser/desktop."
750
+ : "No browser open"}
751
+ </p>
660
752
  <p className="mt-1 text-og-control leading-5 text-og-muted">
661
- A browser appears here when this agent—or another agent in the workspace—opens one.
753
+ {attachedGenerationLoss
754
+ ? "This Chrome profile is live again, but the previous browser cannot move to the new connection. Use Browser → New browser → Connected Chrome."
755
+ : "A browser appears here when this agent—or another agent in the workspace—opens one."}
662
756
  </p>
663
757
  <BrowserLaunchMenu
758
+ attachedBridges={onlineAttachedBridges}
664
759
  attachedDevices={attached.devices}
665
760
  identities={profiles.identities}
666
761
  creating={creating}
667
762
  mutatingProfile={savingProfile}
668
763
  onCreate={createBrowser}
669
764
  onRestore={(identity) => updateProfile(identity, { status: "active" })}
765
+ setupUrl={browserExtensionSetupUrl}
670
766
  prominent
671
767
  />
672
768
  {registry.error ? (
@@ -686,23 +782,31 @@ export function BrowserViewer({
686
782
  relevantSessionIds={new Set(relevant.map((session) => session.id))}
687
783
  selectedSessionId={selection?.sessionId ?? null}
688
784
  identities={profiles.identities}
785
+ attachedBridges={onlineAttachedBridges}
689
786
  attachedDevices={attached.devices}
690
787
  selectedProfile={selectedProfile}
691
788
  baseRevisionOrdinal={baseRevisionOrdinal}
692
789
  profileHistory={profileHistory}
790
+ authConnections={selectedProfileAuth}
693
791
  creating={creating}
694
792
  savingProfile={savingProfile}
695
793
  refreshing={registry.refreshing || attached.refreshing}
696
794
  interventionCounts={interventionCounts}
697
- onSelect={(browserSessionId) => setSelection({ sessionId: browserSessionId, pinned: true })}
795
+ onSelect={(browserSessionId) =>
796
+ selectBrowserSession({ sessionId: browserSessionId, pinned: true })
797
+ }
698
798
  onFollow={() => {
699
799
  const preferred = relevant[0];
700
- if (preferred) setSelection({ sessionId: preferred.id, pinned: false });
800
+ if (preferred) selectBrowserSession({ sessionId: preferred.id, pinned: false });
701
801
  }}
702
802
  onCreate={createBrowser}
703
803
  onSaveProfile={saveProfileVersion}
704
804
  onUpdateProfile={updateProfile}
805
+ onOpenProfileVersion={(identityId, baseRevisionId) =>
806
+ createBrowser({ kind: "profile", identityId, baseRevisionId })
807
+ }
705
808
  onRefresh={() => void Promise.all([registry.refresh(), attached.refresh()])}
809
+ setupUrl={browserExtensionSetupUrl}
706
810
  />
707
811
  <InteractionInterventionBanner
708
812
  interventions={selectedInterventions}
@@ -787,7 +891,26 @@ export function BrowserViewer({
787
891
  return receipt;
788
892
  }}
789
893
  onReadClipboard={browser.readClipboard}
790
- onReconnect={frames.reconnect}
894
+ onReconnect={
895
+ attachedGenerationLoss || isAttachedChromeGenerationLossError(frames.error)
896
+ ? () => {
897
+ if (replacementChromeDevice) {
898
+ createBrowser({ kind: "attached", device: replacementChromeDevice });
899
+ }
900
+ }
901
+ : frames.reconnect
902
+ }
903
+ reconnectLabel={
904
+ (attachedGenerationLoss || isAttachedChromeGenerationLossError(frames.error)) &&
905
+ replacementChromeDevice
906
+ ? "Open a fresh Connected Chrome"
907
+ : undefined
908
+ }
909
+ reconnectMessage={
910
+ attachedGenerationLoss || isAttachedChromeGenerationLossError(frames.error)
911
+ ? "Chrome reconnected—open a fresh browser/desktop."
912
+ : undefined
913
+ }
791
914
  onError={(cause) => notifyError(cause, "Browser input failed.")}
792
915
  />
793
916
  <BrowserStatusBar
@@ -884,11 +1007,13 @@ function BrowserToolbar(props: {
884
1007
  sessions: BrowserSession[];
885
1008
  relevantSessionIds: Set<string>;
886
1009
  selectedSessionId: string | null;
1010
+ attachedBridges: AttachedBrowserBridge[];
887
1011
  attachedDevices: AttachedBrowserDevice[];
888
1012
  identities: BrowserIdentity[];
889
1013
  selectedProfile: BrowserIdentity | null;
890
1014
  baseRevisionOrdinal: number | null;
891
1015
  profileHistory: { identityId: string; loading: boolean; revisions: BrowserRevision[] } | null;
1016
+ authConnections: SiteAuthConnection[];
892
1017
  creating: boolean;
893
1018
  savingProfile: boolean;
894
1019
  refreshing: boolean;
@@ -901,7 +1026,9 @@ function BrowserToolbar(props: {
901
1026
  identity: BrowserIdentity,
902
1027
  update: { name?: string; status?: "active" | "archived"; defaultRevisionId?: string },
903
1028
  ) => Promise<boolean>;
1029
+ onOpenProfileVersion: (identityId: string, baseRevisionId: string) => void;
904
1030
  onRefresh: () => void;
1031
+ setupUrl?: string | undefined;
905
1032
  }) {
906
1033
  const detailsRef = useRef<HTMLDetailsElement | null>(null);
907
1034
  const selected = props.sessions.find((session) => session.id === props.selectedSessionId);
@@ -957,9 +1084,11 @@ function BrowserToolbar(props: {
957
1084
  identity={props.selectedProfile}
958
1085
  baseRevisionOrdinal={props.baseRevisionOrdinal}
959
1086
  history={props.profileHistory}
1087
+ authConnections={props.authConnections}
960
1088
  saving={props.savingProfile}
961
1089
  onSave={props.onSaveProfile}
962
1090
  onUpdate={props.onUpdateProfile}
1091
+ onOpenVersion={props.onOpenProfileVersion}
963
1092
  />
964
1093
  <button
965
1094
  type="button"
@@ -970,12 +1099,14 @@ function BrowserToolbar(props: {
970
1099
  <RefreshCwIcon className={cn("size-3.5", props.refreshing && "animate-spin")} />
971
1100
  </button>
972
1101
  <BrowserLaunchMenu
1102
+ attachedBridges={props.attachedBridges}
973
1103
  attachedDevices={props.attachedDevices}
974
1104
  identities={props.identities}
975
1105
  creating={props.creating}
976
1106
  mutatingProfile={props.savingProfile}
977
1107
  onCreate={props.onCreate}
978
1108
  onRestore={(identity) => props.onUpdateProfile(identity, { status: "active" })}
1109
+ setupUrl={props.setupUrl}
979
1110
  />
980
1111
  </div>
981
1112
  );
@@ -1065,12 +1196,14 @@ function BrowserSessionGroup(props: {
1065
1196
  }
1066
1197
 
1067
1198
  function BrowserLaunchMenu(props: {
1199
+ attachedBridges: AttachedBrowserBridge[];
1068
1200
  attachedDevices: AttachedBrowserDevice[];
1069
1201
  identities: BrowserIdentity[];
1070
1202
  creating: boolean;
1071
1203
  mutatingProfile: boolean;
1072
1204
  onCreate: (choice?: BrowserLaunchChoice) => void;
1073
1205
  onRestore: (identity: BrowserIdentity) => Promise<boolean>;
1206
+ setupUrl?: string | undefined;
1074
1207
  prominent?: boolean;
1075
1208
  }) {
1076
1209
  const detailsRef = useRef<HTMLDetailsElement | null>(null);
@@ -1113,7 +1246,7 @@ function BrowserLaunchMenu(props: {
1113
1246
  props.prominent ? "left-1/2 top-10 -translate-x-1/2" : "right-0 top-8",
1114
1247
  )}
1115
1248
  >
1116
- {props.attachedDevices.length > 0 ? (
1249
+ {props.attachedDevices.length > 0 || props.attachedBridges.length > 0 ? (
1117
1250
  <div className="pb-1">
1118
1251
  <p className="px-2 pb-1 pt-1 text-[10px] font-medium uppercase tracking-[0.12em] text-og-subtle">
1119
1252
  Connected Chrome
@@ -1138,12 +1271,48 @@ function BrowserLaunchMenu(props: {
1138
1271
  <span className="size-1.5 shrink-0 rounded-full bg-og-status-running" />
1139
1272
  </button>
1140
1273
  ))}
1274
+ {props.attachedBridges.length > 0 ? (
1275
+ props.setupUrl ? (
1276
+ <a
1277
+ href={props.setupUrl}
1278
+ target="_blank"
1279
+ rel="noreferrer"
1280
+ onClick={() => detailsRef.current?.removeAttribute("open")}
1281
+ className="flex w-full items-center gap-2 rounded-og-sm px-2 py-2 text-left transition hover:bg-og-surface-2"
1282
+ >
1283
+ <PlusIcon className="size-3.5 shrink-0 text-og-muted" />
1284
+ <span className="min-w-0 flex-1">
1285
+ <span className="block truncate text-og-control text-og-fg">
1286
+ {props.attachedDevices.length > 0
1287
+ ? "Connect another Chrome profile"
1288
+ : "Connect this Chrome profile"}
1289
+ </span>
1290
+ <span className="block truncate text-og-xs text-og-subtle">
1291
+ Machine connected · Chrome extension missing
1292
+ </span>
1293
+ </span>
1294
+ </a>
1295
+ ) : (
1296
+ <div className="flex w-full items-center gap-2 px-2 py-2 text-left">
1297
+ <MonitorIcon className="size-3.5 shrink-0 text-og-muted" />
1298
+ <span className="min-w-0 flex-1">
1299
+ <span className="block truncate text-og-control text-og-fg">
1300
+ Chrome connection ready
1301
+ </span>
1302
+ <span className="block text-og-xs leading-4 text-og-subtle">
1303
+ Install the OpenGeni Browser extension in the profile you want to use.
1304
+ </span>
1305
+ </span>
1306
+ </div>
1307
+ )
1308
+ ) : null}
1141
1309
  </div>
1142
1310
  ) : null}
1143
1311
  <p
1144
1312
  className={cn(
1145
1313
  "px-2 pb-1 pt-1 text-[10px] font-medium uppercase tracking-[0.12em] text-og-subtle",
1146
- props.attachedDevices.length > 0 && "border-t border-og-border",
1314
+ (props.attachedDevices.length > 0 || props.attachedBridges.length > 0) &&
1315
+ "border-t border-og-border",
1147
1316
  )}
1148
1317
  >
1149
1318
  Open isolated browser
@@ -1213,12 +1382,14 @@ function BrowserProfileMenu(props: {
1213
1382
  identity: BrowserIdentity | null;
1214
1383
  baseRevisionOrdinal: number | null;
1215
1384
  history: { identityId: string; loading: boolean; revisions: BrowserRevision[] } | null;
1385
+ authConnections: SiteAuthConnection[];
1216
1386
  saving: boolean;
1217
1387
  onSave: (newProfileName?: string) => Promise<boolean>;
1218
1388
  onUpdate: (
1219
1389
  identity: BrowserIdentity,
1220
1390
  update: { name?: string; status?: "active" | "archived"; defaultRevisionId?: string },
1221
1391
  ) => Promise<boolean>;
1392
+ onOpenVersion: (identityId: string, baseRevisionId: string) => void;
1222
1393
  }) {
1223
1394
  const detailsRef = useRef<HTMLDetailsElement | null>(null);
1224
1395
  const nameInputId = useId();
@@ -1237,6 +1408,10 @@ function BrowserProfileMenu(props: {
1237
1408
  detailsRef.current?.removeAttribute("open");
1238
1409
  }
1239
1410
  };
1411
+ const openVersion = (identityId: string, baseRevisionId: string) => {
1412
+ detailsRef.current?.removeAttribute("open");
1413
+ props.onOpenVersion(identityId, baseRevisionId);
1414
+ };
1240
1415
  const versionLabel = attached
1241
1416
  ? "live"
1242
1417
  : props.identity
@@ -1299,37 +1474,57 @@ function BrowserProfileMenu(props: {
1299
1474
  {revisions.map((revision) => {
1300
1475
  const isDefault = revision.id === props.identity?.defaultRevisionId;
1301
1476
  const isCurrent = revision.ordinal === props.baseRevisionOrdinal;
1477
+ const materialization = revisionMaterializationSummary(revision);
1302
1478
  return (
1303
- <button
1479
+ <div
1304
1480
  key={revision.id}
1305
- type="button"
1306
- disabled={isDefault || props.saving || props.identity?.status === "archived"}
1307
- onClick={() =>
1308
- void props.onUpdate(props.identity!, { defaultRevisionId: revision.id })
1309
- }
1310
- className="flex w-full items-center gap-2 rounded-og-sm px-2 py-1.5 text-left transition hover:bg-og-surface-2 disabled:cursor-default disabled:opacity-70"
1481
+ className="flex w-full items-center gap-1 rounded-og-sm transition hover:bg-og-surface-2"
1311
1482
  >
1312
- <span className="grid size-4 place-items-center text-og-muted">
1313
- {isDefault ? <CheckIcon className="size-3.5 text-og-status-running" /> : null}
1314
- </span>
1315
- <span className="min-w-0 flex-1 text-og-control text-og-fg">
1316
- Version {revision.ordinal}
1317
- </span>
1318
- <span className="text-[10px] text-og-subtle">
1319
- {isCurrent && isDefault
1320
- ? "current · default"
1321
- : isDefault
1322
- ? "default"
1323
- : isCurrent
1324
- ? "current · Use by default"
1325
- : "Use by default"}
1326
- </span>
1327
- </button>
1483
+ <button
1484
+ type="button"
1485
+ disabled={props.saving || props.identity?.status === "archived"}
1486
+ aria-label={`Open ${props.identity!.name} version ${revision.ordinal}`}
1487
+ onClick={() => openVersion(props.identity!.id, revision.id)}
1488
+ className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left disabled:opacity-50"
1489
+ >
1490
+ <span className="grid size-4 place-items-center text-og-muted">
1491
+ {isCurrent ? <CheckIcon className="size-3.5 text-og-accent" /> : null}
1492
+ </span>
1493
+ <span className="min-w-0 flex-1">
1494
+ <span className="block text-og-control text-og-fg">
1495
+ Version {revision.ordinal}
1496
+ </span>
1497
+ <span className="block truncate text-[10px] text-og-subtle">
1498
+ {materialization.label}
1499
+ </span>
1500
+ </span>
1501
+ {isCurrent ? (
1502
+ <span className="text-[10px] text-og-subtle">Current</span>
1503
+ ) : null}
1504
+ </button>
1505
+ <button
1506
+ type="button"
1507
+ disabled={isDefault || props.saving || props.identity?.status === "archived"}
1508
+ aria-label={
1509
+ isDefault
1510
+ ? `${props.identity!.name} version ${revision.ordinal} is the default`
1511
+ : `Use ${props.identity!.name} version ${revision.ordinal} by default`
1512
+ }
1513
+ title={isDefault ? "Default version" : "Use by default"}
1514
+ onClick={() =>
1515
+ void props.onUpdate(props.identity!, { defaultRevisionId: revision.id })
1516
+ }
1517
+ className="mr-1 shrink-0 rounded-og-sm px-2 py-1 text-[10px] text-og-subtle transition hover:bg-og-surface-3 hover:text-og-fg disabled:cursor-default disabled:opacity-70"
1518
+ >
1519
+ {isDefault ? "Default" : "Make default"}
1520
+ </button>
1521
+ </div>
1328
1522
  );
1329
1523
  })}
1330
1524
  </div>
1331
1525
  <p className="mt-1 px-2 text-[10px] leading-4 text-og-subtle">
1332
- Changing the default affects only browsers opened later.
1526
+ Changing the default affects only browsers opened later. Saved browser data can be
1527
+ copied; a website may still expire or re-verify its own session.
1333
1528
  </p>
1334
1529
  </div>
1335
1530
  ) : props.identity &&
@@ -1339,6 +1534,34 @@ function BrowserProfileMenu(props: {
1339
1534
  <LoaderCircleIcon className="size-3 animate-spin" /> Loading saved versions…
1340
1535
  </p>
1341
1536
  ) : null}
1537
+ {props.identity && props.authConnections.length > 0 ? (
1538
+ <div className="mt-3 border-t border-og-border pt-2">
1539
+ <p className="px-1 text-[10px] font-medium uppercase tracking-[0.12em] text-og-subtle">
1540
+ Login health
1541
+ </p>
1542
+ <div className="mt-1 space-y-1">
1543
+ {props.authConnections.map((connection) => {
1544
+ const health = siteAuthHealth(connection);
1545
+ return (
1546
+ <div
1547
+ key={connection.id}
1548
+ className="flex items-start gap-2 rounded-og-sm px-2 py-1.5"
1549
+ >
1550
+ <span className={cn("mt-1 size-1.5 shrink-0 rounded-full", health.dotClass)} />
1551
+ <span className="min-w-0 flex-1">
1552
+ <span className="block truncate text-og-control text-og-fg">
1553
+ {connection.name}
1554
+ </span>
1555
+ <span className="block truncate text-[10px] text-og-subtle">
1556
+ {connection.accountLabel} · {health.label}
1557
+ </span>
1558
+ </span>
1559
+ </div>
1560
+ );
1561
+ })}
1562
+ </div>
1563
+ </div>
1564
+ ) : null}
1342
1565
  {canSave ? (
1343
1566
  props.identity ? (
1344
1567
  <button
@@ -1427,6 +1650,45 @@ function BrowserProfileMenu(props: {
1427
1650
  );
1428
1651
  }
1429
1652
 
1653
+ function revisionMaterializationSummary(revision: BrowserRevision): { label: string } {
1654
+ const materializations = revision.components.map((component) => component.materialization);
1655
+ const placementBound = materializations.find(
1656
+ (materialization) => materialization.portability === "placement_bound",
1657
+ );
1658
+ if (placementBound) {
1659
+ return {
1660
+ label: placementBound.reason
1661
+ ? `This machine only · ${placementBound.reason}`
1662
+ : "This machine only",
1663
+ };
1664
+ }
1665
+ const providerBound = materializations.find(
1666
+ (materialization) => materialization.portability === "provider_bound",
1667
+ );
1668
+ if (providerBound) {
1669
+ return {
1670
+ label: providerBound.reason ? `Provider-bound · ${providerBound.reason}` : "Provider-bound",
1671
+ };
1672
+ }
1673
+ return { label: "Portable browser data" };
1674
+ }
1675
+
1676
+ function siteAuthHealth(connection: SiteAuthConnection): {
1677
+ label: string;
1678
+ dotClass: string;
1679
+ } {
1680
+ switch (connection.verificationState) {
1681
+ case "verified":
1682
+ return { label: "Verified", dotClass: "bg-og-status-running" };
1683
+ case "needs_repair":
1684
+ return { label: "Sign-in needs attention", dotClass: "bg-og-status-waiting" };
1685
+ case "failed":
1686
+ return { label: "Verification failed", dotClass: "bg-og-status-error" };
1687
+ case "unknown":
1688
+ return { label: "Not checked yet", dotClass: "bg-og-muted" };
1689
+ }
1690
+ }
1691
+
1430
1692
  function MenuButton(props: { children: ReactNode; onClick: () => void; disabled?: boolean }) {
1431
1693
  return (
1432
1694
  <button
@@ -1567,6 +1829,8 @@ function BrowserViewport(props: {
1567
1829
  onAction: (action: BrowserAction, frame: BrowserFrame | null) => Promise<BrowserActionReceipt>;
1568
1830
  onReadClipboard: () => Promise<{ text: string }>;
1569
1831
  onReconnect: () => void;
1832
+ reconnectLabel?: string | undefined;
1833
+ reconnectMessage?: string | undefined;
1570
1834
  onError: (cause: unknown) => void;
1571
1835
  }) {
1572
1836
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
@@ -1596,6 +1860,7 @@ function BrowserViewport(props: {
1596
1860
  const readClipboardRef = useRef(props.onReadClipboard);
1597
1861
  const errorRef = useRef(props.onError);
1598
1862
  const actionTailRef = useRef<Promise<void>>(Promise.resolve());
1863
+ const actionQueueEpochRef = useRef(0);
1599
1864
  const queuedFrameRef = useRef<BrowserFrame | null>(null);
1600
1865
  const decodingFrameRef = useRef(false);
1601
1866
  const mountedRef = useRef(true);
@@ -1677,13 +1942,19 @@ function BrowserViewport(props: {
1677
1942
  frame: BrowserFrame | null,
1678
1943
  after?: (receipt: BrowserActionReceipt) => Promise<void>,
1679
1944
  ) => {
1945
+ const epoch = actionQueueEpochRef.current;
1680
1946
  actionTailRef.current = actionTailRef.current
1681
1947
  .catch(() => undefined)
1682
1948
  .then(async () => {
1949
+ if (epoch !== actionQueueEpochRef.current) return;
1683
1950
  const receipt = await actionRef.current(action, frame);
1684
1951
  await after?.(receipt);
1685
1952
  })
1686
- .catch((cause) => errorRef.current(cause));
1953
+ .catch((cause) => {
1954
+ // Do not replay or continue input collected behind a failed request.
1955
+ actionQueueEpochRef.current += 1;
1956
+ errorRef.current(cause);
1957
+ });
1687
1958
  },
1688
1959
  [],
1689
1960
  );
@@ -1930,6 +2201,8 @@ function BrowserViewport(props: {
1930
2201
  error={props.connectionError}
1931
2202
  onAction={(action) => enqueue(action, null)}
1932
2203
  onReconnect={props.onReconnect}
2204
+ reconnectLabel={props.reconnectLabel}
2205
+ reconnectMessage={props.reconnectMessage}
1933
2206
  />
1934
2207
  ) : null}
1935
2208
  {props.mutating ? (
@@ -1948,7 +2221,12 @@ function SemanticBrowserFallback(props: {
1948
2221
  error: Error | null;
1949
2222
  onAction: (action: BrowserAction) => void;
1950
2223
  onReconnect: () => void;
2224
+ reconnectLabel?: string | undefined;
2225
+ reconnectMessage?: string | undefined;
1951
2226
  }) {
2227
+ const controlFailure = interactionControlFailureFromError(props.error);
2228
+ const generationLoss =
2229
+ Boolean(props.reconnectMessage) || isAttachedChromeGenerationLossError(props.error);
1952
2230
  const nodes = semanticNodes(
1953
2231
  props.observation?.semantic?.kind === "snapshot" ? props.observation.semantic.roots : [],
1954
2232
  );
@@ -1965,13 +2243,20 @@ function SemanticBrowserFallback(props: {
1965
2243
  <LoaderCircleIcon className="size-4 animate-spin text-og-muted" />
1966
2244
  )}
1967
2245
  <p className="text-og-menu font-medium text-og-fg">
1968
- {props.error
1969
- ? "Live view disconnected"
1970
- : props.supportsLiveFrames
1971
- ? browserConnectionLabel(props.connectionState)
1972
- : "Semantic browser"}
2246
+ {generationLoss
2247
+ ? "Chrome reconnected—open a fresh browser/desktop."
2248
+ : props.error
2249
+ ? "Live view disconnected"
2250
+ : props.supportsLiveFrames
2251
+ ? browserConnectionLabel(props.connectionState)
2252
+ : "Semantic browser"}
1973
2253
  </p>
1974
2254
  </div>
2255
+ {props.error || props.reconnectMessage ? (
2256
+ <p className="mt-2 text-og-control leading-5 text-og-muted">
2257
+ {props.reconnectMessage ?? controlFailure?.message ?? props.error?.message}
2258
+ </p>
2259
+ ) : null}
1975
2260
  {interactive.length > 0 ? (
1976
2261
  <div className="mt-3 border-t border-og-border pt-3">
1977
2262
  <p className="mb-2 text-og-xs text-og-subtle">
@@ -1998,13 +2283,14 @@ function SemanticBrowserFallback(props: {
1998
2283
  </div>
1999
2284
  </div>
2000
2285
  ) : null}
2001
- {props.error && props.supportsLiveFrames ? (
2286
+ {props.error && props.supportsLiveFrames && (!generationLoss || props.reconnectLabel) ? (
2002
2287
  <button
2003
2288
  type="button"
2004
2289
  onClick={props.onReconnect}
2005
2290
  className="mt-3 text-og-control font-medium text-og-accent hover:underline"
2006
2291
  >
2007
- Reconnect
2292
+ {props.reconnectLabel ??
2293
+ (controlFailure?.retryable === false ? "Try again" : "Reconnect")}
2008
2294
  </button>
2009
2295
  ) : null}
2010
2296
  </div>
@@ -2052,10 +2338,10 @@ function BrowserStatusBar(props: {
2052
2338
  type="button"
2053
2339
  onClick={props.onOpenComputer}
2054
2340
  className="flex items-center gap-1 rounded px-1.5 py-0.5 transition hover:bg-og-surface-2 hover:text-og-fg"
2055
- aria-label="Open this browser window in Computer"
2341
+ aria-label="Open this browser window in Desktop"
2056
2342
  >
2057
2343
  <MonitorIcon className="size-3" />
2058
- Computer
2344
+ Desktop
2059
2345
  </button>
2060
2346
  ) : null}
2061
2347
  <button
@@ -2638,6 +2924,23 @@ function isLiveBrowser(session: BrowserSession): boolean {
2638
2924
  return !["ending", "ended", "failed", "lost"].includes(session.lifecycle);
2639
2925
  }
2640
2926
 
2927
+ function attachedChromeGenerationLoss(
2928
+ sessions: readonly BrowserSession[],
2929
+ ): { deviceId: string } | null {
2930
+ const lost = sessions.find(
2931
+ (session) =>
2932
+ session.lifecycle === "lost" &&
2933
+ session.failureCode === "controller_transition_expired" &&
2934
+ session.placement.kind === "attached_device",
2935
+ );
2936
+ return lost?.placement.kind === "attached_device" ? { deviceId: lost.placement.deviceId } : null;
2937
+ }
2938
+
2939
+ function isAttachedChromeGenerationLossError(error: Error | null): boolean {
2940
+ if (!error) return false;
2941
+ return /placement instance changed|controller authority changed/i.test(error.message);
2942
+ }
2943
+
2641
2944
  function countInterventions(
2642
2945
  interventions: readonly InteractionIntervention[],
2643
2946
  ): Map<string, number> {