@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
@@ -1,11 +1,14 @@
1
1
  import type {
2
2
  ComputerAction,
3
+ ComputerClipboard,
3
4
  ComputerFrame,
4
5
  ComputerObservation,
5
6
  ComputerSession,
6
7
  ComputerTarget,
8
+ InteractionIntervention,
7
9
  InteractionSemanticNode,
8
10
  } from "@opengeni/sdk/interaction";
11
+ import type { DesktopStreamCapability } from "@opengeni/sdk";
9
12
  import {
10
13
  ChevronDownIcon,
11
14
  CircleAlertIcon,
@@ -20,6 +23,7 @@ import {
20
23
  RotateCcwIcon,
21
24
  } from "lucide-react";
22
25
  import {
26
+ type ClipboardEvent,
23
27
  type KeyboardEvent,
24
28
  type MouseEvent,
25
29
  type PointerEvent,
@@ -37,8 +41,12 @@ import {
37
41
  } from "../hooks/use-computer-frame-stream";
38
42
  import { useComputerSession } from "../hooks/use-computer-session";
39
43
  import { useComputerSessions } from "../hooks/use-computer-sessions";
44
+ import { useInteractionInterventions } from "../hooks/use-interaction-interventions";
40
45
  import { cn } from "../lib/cn";
46
+ import { copyTextToClipboard } from "../lib/clipboard";
41
47
  import type { EmbeddedComputerInteractionClientOverride } from "../session-context";
48
+ import { InteractionInterventionBanner } from "./interaction-intervention-banner";
49
+ import { DesktopViewer } from "./desktop-viewer";
42
50
 
43
51
  export type ComputerViewerNotification = { kind: "error" | "info"; message: string };
44
52
 
@@ -75,19 +83,39 @@ export function ComputerViewer({
75
83
  const registry = useComputerSessions({ ...override, sessionId, enabled });
76
84
  const createSession = registry.create;
77
85
  const refreshRegistry = registry.refresh;
78
- const liveSessions = useMemo(
79
- () => registry.sessions.filter((session) => isLiveComputer(session)),
80
- [registry.sessions],
81
- );
82
- const relevant = useMemo(
86
+ const liveRelevant = useMemo(
83
87
  () => registry.relevantSessions.filter((session) => isLiveComputer(session)),
84
88
  [registry.relevantSessions],
85
89
  );
90
+ const recentRelevantFailure = useMemo(
91
+ () =>
92
+ liveRelevant.length === 0
93
+ ? (registry.relevantSessions.find((session) =>
94
+ ["failed", "lost"].includes(session.lifecycle),
95
+ ) ?? null)
96
+ : null,
97
+ [liveRelevant.length, registry.relevantSessions],
98
+ );
99
+ const liveSessions = useMemo(() => {
100
+ const sessions = registry.sessions.filter((session) => isLiveComputer(session));
101
+ return recentRelevantFailure ? [...sessions, recentRelevantFailure] : sessions;
102
+ }, [recentRelevantFailure, registry.sessions]);
103
+ const relevant = useMemo(
104
+ () =>
105
+ liveRelevant.length > 0 ? liveRelevant : recentRelevantFailure ? [recentRelevantFailure] : [],
106
+ [liveRelevant, recentRelevantFailure],
107
+ );
86
108
  const [selection, setSelection] = useState<ComputerSelection>(null);
109
+ const interventions = useInteractionInterventions({
110
+ ...override,
111
+ enabled,
112
+ resourceKind: "computer_session",
113
+ });
87
114
  const [creating, setCreating] = useState(false);
88
115
  const [showControls, setShowControls] = useState(false);
89
116
  const previousSessionIdRef = useRef(sessionId);
90
117
  const handledRequestRef = useRef<string | null>(null);
118
+ const seenInterventionIdsRef = useRef(new Set<string>());
91
119
 
92
120
  const notifyError = useCallback(
93
121
  (cause: unknown, fallback: string) => {
@@ -120,17 +148,19 @@ export function ComputerViewer({
120
148
  }
121
149
 
122
150
  const selectedStillLive = liveSessions.some((session) => session.id === selection?.sessionId);
123
- const preferred = relevant[0] ?? liveSessions[0] ?? null;
151
+ // Peer computers remain one click away, but never become the implicit
152
+ // computer for another agent. Otherwise opening a sandbox's Computer tab
153
+ // can silently select—and send input to—a connected user's Mac.
154
+ if (selection?.pinned && selectedStillLive) return;
155
+ const preferred = relevant[0] ?? null;
124
156
  if (!preferred) {
125
157
  if (selection) setSelection(null);
126
158
  return;
127
159
  }
128
- if (!selectedStillLive || !selection) {
129
- setSelection({ sessionId: preferred.id, pinned: false });
130
- return;
131
- }
132
- if (!selection.pinned && relevant[0] && selection.sessionId !== relevant[0].id) {
133
- setSelection({ sessionId: relevant[0].id, pinned: false });
160
+ if (!selectedStillLive || !selection?.pinned) {
161
+ if (selection?.sessionId !== preferred.id || selection.pinned) {
162
+ setSelection({ sessionId: preferred.id, pinned: false });
163
+ }
134
164
  }
135
165
  }, [liveSessions, relevant, requestedComputerRequestId, requestedComputerSessionId, selection]);
136
166
 
@@ -138,6 +168,17 @@ export function ComputerViewer({
138
168
  () => liveSessions.find((session) => session.id === selection?.sessionId) ?? null,
139
169
  [liveSessions, selection?.sessionId],
140
170
  );
171
+ const interventionCounts = useMemo(
172
+ () => countInterventions(interventions.interventions),
173
+ [interventions.interventions],
174
+ );
175
+ const selectedInterventions = useMemo(
176
+ () =>
177
+ interventions.interventions.filter(
178
+ (intervention) => intervention.resourceId === selection?.sessionId,
179
+ ),
180
+ [interventions.interventions, selection?.sessionId],
181
+ );
141
182
  const controllerReady = selectedRegistrySession?.lifecycle === "active";
142
183
  const computer = useComputerSession({
143
184
  ...override,
@@ -152,7 +193,13 @@ export function ComputerViewer({
152
193
  computerSessionId: selection?.sessionId ?? null,
153
194
  targetId: computer.selectedTarget?.id ?? null,
154
195
  enabled: enabled && selection !== null && controllerReady && computer.selectedTarget !== null,
155
- stream: { format: "jpeg", quality: 82, maxWidth: 1_920, maxHeight: 1_200 },
196
+ stream: {
197
+ format: "jpeg",
198
+ quality: 78,
199
+ maxWidth: 1_920,
200
+ maxHeight: 1_200,
201
+ everyNthFrame: 1,
202
+ },
156
203
  ...(webSocketFactory ? { webSocketFactory } : {}),
157
204
  });
158
205
  const displayedFrame =
@@ -161,9 +208,60 @@ export function ComputerViewer({
161
208
  frames.frame.targetId === computer.selectedTarget?.id
162
209
  ? frames.frame
163
210
  : null;
211
+ const rfbStream =
212
+ frames.attachment?.stream.kind === "direct_rfb" ? frames.attachment.stream : null;
213
+ const rfbCapability = useMemo<DesktopStreamCapability | null>(() => {
214
+ if (!rfbStream || !frames.attachment) return null;
215
+ const bounds = computer.selectedTarget?.bounds;
216
+ return {
217
+ transport: "vnc-ws",
218
+ client: "novnc",
219
+ mode: "interactive",
220
+ url: rfbStream.url,
221
+ token: null,
222
+ expiresAt: frames.attachment.expiresAt,
223
+ resolution: [bounds?.width ?? 1_440, bounds?.height ?? 900],
224
+ unredacted: true,
225
+ requiresAcknowledgment: false,
226
+ acknowledged: true,
227
+ shared: false,
228
+ sharedSessionIds: [],
229
+ reason: null,
230
+ };
231
+ }, [computer.selectedTarget?.bounds, frames.attachment, rfbStream]);
164
232
  const machineLocked = selectedRegistrySession?.failureCode === "machine_locked";
165
233
  const currentIds = useMemo(() => new Set(relevant.map((session) => session.id)), [relevant]);
166
234
 
235
+ useEffect(() => {
236
+ for (const intervention of interventions.interventions) {
237
+ if (seenInterventionIdsRef.current.has(intervention.id)) continue;
238
+ seenInterventionIdsRef.current.add(intervention.id);
239
+ onNotify?.({
240
+ kind: "info",
241
+ message: `${interventionTitle(intervention)}: ${intervention.reason}`,
242
+ });
243
+ }
244
+ }, [interventions.interventions, onNotify]);
245
+
246
+ const resolveIntervention = useCallback(
247
+ (intervention: InteractionIntervention, outcome: "completed" | "dismissed") => {
248
+ void interventions
249
+ .resolve(intervention.id, {
250
+ expectedVersion: intervention.version,
251
+ outcome,
252
+ })
253
+ .then(() => {
254
+ onNotify?.({
255
+ kind: "info",
256
+ message:
257
+ outcome === "completed" ? "Agent notified. Continuing work." : "Request cancelled.",
258
+ });
259
+ })
260
+ .catch((cause) => notifyError(cause, "Could not update the computer request."));
261
+ },
262
+ [interventions, notifyError, onNotify],
263
+ );
264
+
167
265
  const createComputer = useCallback(() => {
168
266
  if (creating) return;
169
267
  setCreating(true);
@@ -187,6 +285,43 @@ export function ComputerViewer({
187
285
  [act, actFromFrame],
188
286
  );
189
287
 
288
+ const copyFromRfb = useCallback(
289
+ (event: ClipboardEvent<HTMLDivElement>) => {
290
+ if (!rfbStream || computer.session?.capabilities?.clipboard !== true) return;
291
+ event.preventDefault();
292
+ event.stopPropagation();
293
+ void perform({ type: "clipboard", operation: "copy" }, null)
294
+ .then(() => computer.readClipboard())
295
+ .then(async (clipboard) => {
296
+ if (clipboard.text && !(await copyTextToClipboard(clipboard.text))) {
297
+ throw new Error("Computer text could not be copied to the local clipboard");
298
+ }
299
+ })
300
+ .catch((cause) => notifyError(cause, "Could not copy from the computer."));
301
+ },
302
+ [computer, notifyError, perform, rfbStream],
303
+ );
304
+
305
+ const pasteIntoRfb = useCallback(
306
+ (text: string): boolean => {
307
+ if (!rfbStream || computer.session?.capabilities?.clipboard !== true) return false;
308
+ // Keep paste on the canonical ComputerSession action path. RFB
309
+ // ClientCutText synchronization varies by server and can acknowledge a
310
+ // local paste without ever updating the remote graphical seat. These two
311
+ // awaited actions run against the exact selected ComputerSession/target.
312
+ // Read-after-write is the causal readiness barrier on X11: clipboard
313
+ // ownership is asynchronous, so a successful write response alone does
314
+ // not prove that the graphical seat can already serve the selection.
315
+ void (async () => {
316
+ await perform({ type: "clipboard", operation: "write", text }, null);
317
+ assertExactComputerClipboard(await computer.readClipboard(), text);
318
+ await perform({ type: "clipboard", operation: "paste" }, null);
319
+ })().catch((cause) => notifyError(cause, "Could not paste into the computer."));
320
+ return true;
321
+ },
322
+ [computer, notifyError, perform, rfbStream],
323
+ );
324
+
190
325
  if (!enabled) return null;
191
326
  if (registry.loading && liveSessions.length === 0) {
192
327
  return (
@@ -238,6 +373,7 @@ export function ComputerViewer({
238
373
  selectedSessionId={selection?.sessionId ?? null}
239
374
  creating={creating}
240
375
  refreshing={registry.refreshing}
376
+ interventionCounts={interventionCounts}
241
377
  onSelect={(computerSessionId) =>
242
378
  setSelection({ sessionId: computerSessionId, pinned: true })
243
379
  }
@@ -247,8 +383,30 @@ export function ComputerViewer({
247
383
  onCreate={createComputer}
248
384
  onRefresh={() => void Promise.all([refreshRegistry(), refreshComputer()])}
249
385
  />
250
- {selectedRegistrySession && !controllerReady ? (
251
- <ComputerLifecyclePanel session={selectedRegistrySession} onRefresh={registry.refresh} />
386
+ <InteractionInterventionBanner
387
+ interventions={selectedInterventions}
388
+ activeTargetId={computer.selectedTarget?.id ?? null}
389
+ mutating={interventions.mutating}
390
+ onOpen={(intervention) =>
391
+ void computer
392
+ .selectTarget(intervention.targetId)
393
+ .catch((cause) => notifyError(cause, "Could not open the requested computer view."))
394
+ }
395
+ onResolve={resolveIntervention}
396
+ />
397
+ {!selectedRegistrySession ? (
398
+ <ComputerUnselectedPanel
399
+ peerCount={liveSessions.length}
400
+ creating={creating}
401
+ onCreate={createComputer}
402
+ />
403
+ ) : !controllerReady ? (
404
+ <ComputerLifecyclePanel
405
+ session={selectedRegistrySession}
406
+ creating={creating}
407
+ onRefresh={registry.refresh}
408
+ onRetry={createComputer}
409
+ />
252
410
  ) : (
253
411
  <>
254
412
  <ComputerTargetRail
@@ -262,18 +420,35 @@ export function ComputerViewer({
262
420
  }
263
421
  />
264
422
  <div className="flex min-h-0 flex-1">
265
- <ComputerViewport
266
- frame={machineLocked ? null : displayedFrame}
267
- observation={computer.observation}
268
- target={computer.selectedTarget}
269
- machineLocked={machineLocked}
270
- connectionState={frames.state}
271
- connectionError={frames.error ?? computer.error}
272
- mutating={computer.mutating}
273
- onAction={perform}
274
- onReconnect={frames.reconnect}
275
- onError={(cause) => notifyError(cause, "Computer input failed.")}
276
- />
423
+ {rfbStream ? (
424
+ <div className="relative min-h-0 flex-1 bg-black" onCopyCapture={copyFromRfb}>
425
+ <DesktopViewer
426
+ capability={rfbCapability}
427
+ interactive
428
+ showControlToggle={false}
429
+ webSocketProtocols={rfbStream.protocols}
430
+ onPasteText={pasteIntoRfb}
431
+ targetPlatform={computer.session?.platform ?? null}
432
+ className="h-full"
433
+ />
434
+ </div>
435
+ ) : (
436
+ <ComputerViewport
437
+ frame={machineLocked ? null : displayedFrame}
438
+ observation={computer.observation}
439
+ target={computer.selectedTarget}
440
+ machineLocked={machineLocked}
441
+ connectionState={frames.state}
442
+ connectionError={frames.error ?? computer.error}
443
+ mutating={computer.mutating}
444
+ backgroundActions={computer.session?.capabilities?.backgroundActions === true}
445
+ clipboardEnabled={computer.session?.capabilities?.clipboard === true}
446
+ onAction={perform}
447
+ onReadClipboard={computer.readClipboard}
448
+ onReconnect={frames.reconnect}
449
+ onError={(cause) => notifyError(cause, "Computer input failed.")}
450
+ />
451
+ )}
277
452
  {showControls ? (
278
453
  <ComputerSemanticPanel
279
454
  observation={computer.observation}
@@ -301,12 +476,48 @@ export function ComputerViewer({
301
476
  );
302
477
  }
303
478
 
479
+ function ComputerUnselectedPanel(props: {
480
+ peerCount: number;
481
+ creating: boolean;
482
+ onCreate: () => void;
483
+ }) {
484
+ return (
485
+ <div className="grid min-h-0 flex-1 place-items-center bg-og-bg p-6 text-center">
486
+ <div className="max-w-sm">
487
+ <span className="mx-auto grid size-10 place-items-center rounded-og-md border border-og-border bg-og-surface-1 text-og-muted">
488
+ <MonitorIcon className="size-4.5" />
489
+ </span>
490
+ <p className="mt-3 text-og-menu font-medium text-og-fg">No computer for this agent</p>
491
+ <p className="mt-1 text-og-control leading-5 text-og-muted">
492
+ {props.peerCount === 1
493
+ ? "One workspace computer is available from the computer menu."
494
+ : `${props.peerCount} workspace computers are available from the computer menu.`}
495
+ </p>
496
+ <button
497
+ type="button"
498
+ disabled={props.creating}
499
+ onClick={props.onCreate}
500
+ className="mt-4 inline-flex h-8 items-center gap-1.5 rounded-og-sm border border-og-border bg-og-surface-1 px-3 text-og-control font-medium text-og-fg transition hover:bg-og-surface-2 disabled:cursor-not-allowed disabled:opacity-50"
501
+ >
502
+ {props.creating ? (
503
+ <LoaderCircleIcon className="size-3.5 animate-spin" />
504
+ ) : (
505
+ <PlusIcon className="size-3.5" />
506
+ )}
507
+ {props.creating ? "Opening…" : "New computer"}
508
+ </button>
509
+ </div>
510
+ </div>
511
+ );
512
+ }
513
+
304
514
  function ComputerToolbar(props: {
305
515
  sessions: ComputerSession[];
306
516
  relevantSessionIds: Set<string>;
307
517
  selectedSessionId: string | null;
308
518
  creating: boolean;
309
519
  refreshing: boolean;
520
+ interventionCounts: Map<string, number>;
310
521
  onSelect: (id: string) => void;
311
522
  onFollow: () => void;
312
523
  onCreate: () => void;
@@ -326,6 +537,9 @@ function ComputerToolbar(props: {
326
537
  <summary className="flex h-7 max-w-52 cursor-pointer list-none items-center gap-2 rounded-og-sm px-2 text-og-control text-og-fg transition hover:bg-og-surface-2 [&::-webkit-details-marker]:hidden">
327
538
  <MonitorIcon className="size-3.5 shrink-0 text-og-muted" />
328
539
  <span className="truncate font-medium">{selected?.name ?? "Computer"}</span>
540
+ {(props.interventionCounts.get(selected?.id ?? "") ?? 0) > 0 ? (
541
+ <span className="size-1.5 shrink-0 rounded-full bg-og-status-waiting" />
542
+ ) : null}
329
543
  <ChevronDownIcon className="size-3 shrink-0 text-og-subtle" />
330
544
  </summary>
331
545
  <div className="absolute left-0 top-8 z-30 w-72 overflow-hidden rounded-og-md border border-og-border bg-og-surface-1 p-1 shadow-xl">
@@ -333,12 +547,14 @@ function ComputerToolbar(props: {
333
547
  label="Current agent"
334
548
  sessions={current}
335
549
  selectedId={props.selectedSessionId}
550
+ interventionCounts={props.interventionCounts}
336
551
  onSelect={choose}
337
552
  />
338
553
  <ComputerSessionGroup
339
554
  label="Other agents"
340
555
  sessions={others}
341
556
  selectedId={props.selectedSessionId}
557
+ interventionCounts={props.interventionCounts}
342
558
  onSelect={choose}
343
559
  />
344
560
  <div className="mt-1 flex gap-1 border-t border-og-border pt-1">
@@ -383,6 +599,7 @@ function ComputerSessionGroup(props: {
383
599
  label: string;
384
600
  sessions: ComputerSession[];
385
601
  selectedId: string | null;
602
+ interventionCounts: Map<string, number>;
386
603
  onSelect: (id: string) => void;
387
604
  }) {
388
605
  if (props.sessions.length === 0) return null;
@@ -391,30 +608,38 @@ function ComputerSessionGroup(props: {
391
608
  <p className="px-2 pb-1 text-[10px] font-medium uppercase tracking-[0.12em] text-og-subtle">
392
609
  {props.label}
393
610
  </p>
394
- {props.sessions.map((session) => (
395
- <button
396
- key={session.id}
397
- type="button"
398
- onClick={() => props.onSelect(session.id)}
399
- className={cn(
400
- "flex w-full items-center gap-2 rounded-og-sm px-2 py-1.5 text-left transition hover:bg-og-surface-2",
401
- session.id === props.selectedId && "bg-og-surface-2",
402
- )}
403
- >
404
- <span
611
+ {props.sessions.map((session) => {
612
+ const interventionCount = props.interventionCounts.get(session.id) ?? 0;
613
+ return (
614
+ <button
615
+ key={session.id}
616
+ type="button"
617
+ onClick={() => props.onSelect(session.id)}
405
618
  className={cn(
406
- "size-1.5 rounded-full",
407
- session.lifecycle === "active" ? "bg-og-status-running" : "bg-og-muted",
619
+ "flex w-full items-center gap-2 rounded-og-sm px-2 py-1.5 text-left transition hover:bg-og-surface-2",
620
+ session.id === props.selectedId && "bg-og-surface-2",
408
621
  )}
409
- />
410
- <span className="min-w-0 flex-1">
411
- <span className="block truncate text-og-control text-og-fg">{session.name}</span>
412
- <span className="block truncate text-og-xs text-og-subtle">
413
- {platformLabel(session)} · {placementLabel(session)}
622
+ >
623
+ <span
624
+ className={cn(
625
+ "size-1.5 rounded-full",
626
+ session.lifecycle === "active" ? "bg-og-status-running" : "bg-og-muted",
627
+ )}
628
+ />
629
+ <span className="min-w-0 flex-1">
630
+ <span className="block truncate text-og-control text-og-fg">{session.name}</span>
631
+ <span className="block truncate text-og-xs text-og-subtle">
632
+ {platformLabel(session)} · {placementLabel(session)}
633
+ </span>
414
634
  </span>
415
- </span>
416
- </button>
417
- ))}
635
+ {interventionCount > 0 ? (
636
+ <span className="rounded-full bg-og-status-waiting/10 px-1.5 py-0.5 text-[10px] font-medium text-og-status-waiting">
637
+ {interventionCount}
638
+ </span>
639
+ ) : null}
640
+ </button>
641
+ );
642
+ })}
418
643
  </div>
419
644
  );
420
645
  }
@@ -438,12 +663,13 @@ function ComputerTargetRail(props: {
438
663
  loading: boolean;
439
664
  onSelect: (id: string) => void;
440
665
  }) {
666
+ const visualTargets = props.targets.filter(isRenderableComputerView);
441
667
  return (
442
668
  <div
443
669
  className="flex h-10 shrink-0 items-center gap-1 overflow-x-auto border-b border-og-border bg-og-surface-0 px-2"
444
670
  aria-label="Computer views"
445
671
  >
446
- {props.targets.map((target) => (
672
+ {visualTargets.map((target) => (
447
673
  <button
448
674
  key={target.id}
449
675
  type="button"
@@ -473,18 +699,30 @@ function ComputerTargetRail(props: {
473
699
  {props.loading ? (
474
700
  <LoaderCircleIcon className="ml-1 size-3.5 animate-spin text-og-muted" />
475
701
  ) : null}
476
- {!props.loading && props.targets.length === 0 ? (
702
+ {!props.loading && visualTargets.length === 0 ? (
477
703
  <span className="text-og-xs text-og-subtle">Waiting for apps and screens…</span>
478
704
  ) : null}
479
705
  </div>
480
706
  );
481
707
  }
482
708
 
709
+ function isRenderableComputerView(target: ComputerTarget): boolean {
710
+ if (target.kind === "screen") return true;
711
+ if (target.kind !== "window" || !target.bounds) return false;
712
+ // AX exposes applications and tiny utility/menu windows as valid semantic
713
+ // targets. Keep those available to tools, but do not present them as visual
714
+ // desktop tabs when they cannot form a useful live view.
715
+ return target.bounds.width >= 160 && target.bounds.height >= 90;
716
+ }
717
+
483
718
  function ComputerLifecyclePanel(props: {
484
719
  session: ComputerSession;
720
+ creating: boolean;
485
721
  onRefresh: () => Promise<void>;
722
+ onRetry: () => void;
486
723
  }) {
487
724
  const failed = ["failed", "lost", "repair_required"].includes(props.session.lifecycle);
725
+ const retryCreatesSession = ["failed", "lost"].includes(props.session.lifecycle);
488
726
  return (
489
727
  <div className="grid min-h-0 flex-1 place-items-center bg-og-bg p-6">
490
728
  <div className="max-w-sm text-center">
@@ -497,16 +735,22 @@ function ComputerLifecyclePanel(props: {
497
735
  {failed ? "Computer needs attention" : lifecycleLabel(props.session.lifecycle)}
498
736
  </p>
499
737
  <p className="mt-1 text-og-control leading-5 text-og-muted">
500
- {props.session.failureCode ??
738
+ {computerFailureMessage(props.session) ??
501
739
  "The computer is being prepared on its placement. It will appear here when ready."}
502
740
  </p>
503
741
  {failed ? (
504
742
  <button
505
743
  type="button"
506
- onClick={() => void props.onRefresh()}
744
+ onClick={() => (retryCreatesSession ? props.onRetry() : void props.onRefresh())}
745
+ disabled={props.creating}
507
746
  className="mt-4 inline-flex h-8 items-center gap-1.5 rounded-og-sm border border-og-border bg-og-surface-1 px-3 text-og-control font-medium text-og-fg transition hover:bg-og-surface-2"
508
747
  >
509
- <RotateCcwIcon className="size-3.5" /> Check again
748
+ {props.creating ? (
749
+ <LoaderCircleIcon className="size-3.5 animate-spin" />
750
+ ) : (
751
+ <RotateCcwIcon className="size-3.5" />
752
+ )}
753
+ {retryCreatesSession ? "Try again" : "Check again"}
510
754
  </button>
511
755
  ) : null}
512
756
  </div>
@@ -522,7 +766,10 @@ function ComputerViewport(props: {
522
766
  connectionState: string;
523
767
  connectionError: Error | null;
524
768
  mutating: boolean;
769
+ backgroundActions: boolean;
770
+ clipboardEnabled: boolean;
525
771
  onAction: (action: ComputerAction, frame: ComputerFrame | null) => Promise<void>;
772
+ onReadClipboard: () => Promise<ComputerClipboard>;
526
773
  onReconnect: () => void;
527
774
  onError: (cause: unknown) => void;
528
775
  }) {
@@ -544,58 +791,101 @@ function ComputerViewport(props: {
544
791
  frame: ComputerFrame;
545
792
  timer: ReturnType<typeof setTimeout> | null;
546
793
  } | null>(null);
794
+ const pendingTextRef = useRef<{
795
+ text: string;
796
+ timer: ReturnType<typeof setTimeout>;
797
+ } | null>(null);
547
798
  const actionRef = useRef(props.onAction);
799
+ const readClipboardRef = useRef(props.onReadClipboard);
548
800
  const errorRef = useRef(props.onError);
549
801
  const actionTailRef = useRef<Promise<void>>(Promise.resolve());
802
+ const queuedFrameRef = useRef<ComputerFrame | null>(null);
803
+ const decodingFrameRef = useRef(false);
804
+ const mountedRef = useRef(true);
550
805
  actionRef.current = props.onAction;
806
+ readClipboardRef.current = props.onReadClipboard;
551
807
  errorRef.current = props.onError;
808
+ const rawInputEnabled =
809
+ !props.backgroundActions || props.target?.kind === "screen" || props.target?.focused === true;
552
810
 
553
- useEffect(() => {
554
- const frame = props.frame;
555
- const canvas = canvasRef.current;
556
- if (!frame || !canvas) return;
557
- let cancelled = false;
558
- let objectUrl: string | null = null;
811
+ const paintQueuedFrames = useCallback(() => {
812
+ if (decodingFrameRef.current) return;
813
+ decodingFrameRef.current = true;
559
814
  void (async () => {
560
815
  try {
561
- const blob = new Blob([frame.data.slice().buffer], { type: frame.mediaType });
562
- if (typeof createImageBitmap === "function") {
563
- const bitmap = await createImageBitmap(blob);
564
- if (cancelled) {
565
- bitmap.close();
566
- return;
816
+ while (mountedRef.current) {
817
+ const frame = queuedFrameRef.current;
818
+ queuedFrameRef.current = null;
819
+ if (!frame) break;
820
+ let objectUrl: string | null = null;
821
+ try {
822
+ const blob = new Blob([frame.data.slice().buffer], { type: frame.mediaType });
823
+ if (typeof createImageBitmap === "function") {
824
+ const bitmap = await createImageBitmap(blob);
825
+ try {
826
+ const canvas = canvasRef.current;
827
+ if (mountedRef.current && canvas) {
828
+ paintCanvas(canvas, bitmap, frame.width, frame.height);
829
+ }
830
+ } finally {
831
+ bitmap.close();
832
+ }
833
+ continue;
834
+ }
835
+ objectUrl = URL.createObjectURL(blob);
836
+ const image = await loadImage(objectUrl);
837
+ const canvas = canvasRef.current;
838
+ if (mountedRef.current && canvas) {
839
+ paintCanvas(canvas, image, frame.width, frame.height);
840
+ }
841
+ } catch (cause) {
842
+ if (mountedRef.current) errorRef.current(cause);
843
+ } finally {
844
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
567
845
  }
568
- paintCanvas(canvas, bitmap, frame.width, frame.height);
569
- bitmap.close();
570
- return;
571
846
  }
572
- objectUrl = URL.createObjectURL(blob);
573
- const image = await loadImage(objectUrl);
574
- if (!cancelled) paintCanvas(canvas, image, frame.width, frame.height);
575
- } catch (cause) {
576
- if (!cancelled) errorRef.current(cause);
847
+ } finally {
848
+ decodingFrameRef.current = false;
849
+ if (mountedRef.current && queuedFrameRef.current) paintQueuedFrames();
577
850
  }
578
851
  })();
852
+ }, []);
853
+
854
+ useEffect(() => {
855
+ mountedRef.current = true;
579
856
  return () => {
580
- cancelled = true;
581
- if (objectUrl) URL.revokeObjectURL(objectUrl);
857
+ mountedRef.current = false;
858
+ queuedFrameRef.current = null;
582
859
  };
583
- }, [props.frame]);
860
+ }, []);
861
+
862
+ useEffect(() => {
863
+ if (!props.frame) return;
864
+ queuedFrameRef.current = props.frame;
865
+ paintQueuedFrames();
866
+ }, [paintQueuedFrames, props.frame]);
584
867
 
585
868
  useEffect(
586
869
  () => () => {
587
870
  if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
588
871
  if (wheelRef.current?.timer) clearTimeout(wheelRef.current.timer);
872
+ if (pendingTextRef.current?.timer) clearTimeout(pendingTextRef.current.timer);
589
873
  },
590
874
  [],
591
875
  );
592
876
 
593
- const enqueue = useCallback((action: ComputerAction, frame: ComputerFrame | null) => {
594
- actionTailRef.current = actionTailRef.current
595
- .catch(() => undefined)
596
- .then(async () => await actionRef.current(action, frame))
597
- .catch((cause) => errorRef.current(cause));
598
- }, []);
877
+ const enqueue = useCallback(
878
+ (action: ComputerAction, frame: ComputerFrame | null, after?: () => Promise<void>) => {
879
+ actionTailRef.current = actionTailRef.current
880
+ .catch(() => undefined)
881
+ .then(async () => {
882
+ await actionRef.current(action, frame);
883
+ await after?.();
884
+ })
885
+ .catch((cause) => errorRef.current(cause));
886
+ },
887
+ [],
888
+ );
599
889
 
600
890
  const point = useCallback(
601
891
  (frame: ComputerFrame, clientX: number, clientY: number) =>
@@ -603,8 +893,35 @@ function ComputerViewport(props: {
603
893
  [],
604
894
  );
605
895
 
896
+ const flushPendingText = useCallback(() => {
897
+ const pending = pendingTextRef.current;
898
+ if (!pending) return;
899
+ clearTimeout(pending.timer);
900
+ pendingTextRef.current = null;
901
+ enqueue({ type: "keyboard", action: "type", value: pending.text }, null);
902
+ }, [enqueue]);
903
+
904
+ const flushPendingClick = useCallback(() => {
905
+ const pending = lastClickRef.current;
906
+ if (!pending) return;
907
+ if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
908
+ clickTimerRef.current = null;
909
+ lastClickRef.current = null;
910
+ enqueue(
911
+ {
912
+ type: "pointer",
913
+ frameId: pending.frame.frameId,
914
+ action: "click",
915
+ x: pending.x,
916
+ y: pending.y,
917
+ },
918
+ pending.frame,
919
+ );
920
+ }, [enqueue]);
921
+
606
922
  const pointerDown = (event: PointerEvent<HTMLCanvasElement>) => {
607
- if (!props.frame || props.mutating) return;
923
+ if (!props.frame || props.mutating || !rawInputEnabled || event.button !== 0) return;
924
+ flushPendingText();
608
925
  pointerStartRef.current = {
609
926
  x: event.clientX,
610
927
  y: event.clientY,
@@ -616,6 +933,7 @@ function ComputerViewport(props: {
616
933
  };
617
934
 
618
935
  const pointerUp = (event: PointerEvent<HTMLCanvasElement>) => {
936
+ if (!rawInputEnabled) return;
619
937
  const start = pointerStartRef.current;
620
938
  pointerStartRef.current = null;
621
939
  if (!start || start.pointerId !== event.pointerId) return;
@@ -623,6 +941,7 @@ function ComputerViewport(props: {
623
941
  const to = point(start.frame, event.clientX, event.clientY);
624
942
  if (!from || !to) return;
625
943
  if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > 5) {
944
+ flushPendingClick();
626
945
  enqueue(
627
946
  {
628
947
  type: "pointer",
@@ -679,8 +998,11 @@ function ComputerViewport(props: {
679
998
 
680
999
  const contextMenu = (event: MouseEvent<HTMLCanvasElement>) => {
681
1000
  event.preventDefault();
1001
+ if (!rawInputEnabled) return;
682
1002
  const frame = props.frame;
683
1003
  if (!frame) return;
1004
+ flushPendingText();
1005
+ flushPendingClick();
684
1006
  const at = point(frame, event.clientX, event.clientY);
685
1007
  if (at) {
686
1008
  enqueue(
@@ -698,10 +1020,13 @@ function ComputerViewport(props: {
698
1020
  };
699
1021
 
700
1022
  const wheel = (event: WheelEvent<HTMLCanvasElement>) => {
1023
+ if (!rawInputEnabled) return;
701
1024
  const frame = props.frame;
702
1025
  if (!frame) return;
703
1026
  const at = point(frame, event.clientX, event.clientY);
704
1027
  if (!at) return;
1028
+ flushPendingText();
1029
+ flushPendingClick();
705
1030
  event.preventDefault();
706
1031
  const pending = wheelRef.current;
707
1032
  if (pending?.timer) clearTimeout(pending.timer);
@@ -733,15 +1058,65 @@ function ComputerViewport(props: {
733
1058
  };
734
1059
 
735
1060
  const keyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
1061
+ if (!rawInputEnabled) return;
1062
+ const command = event.metaKey || event.ctrlKey;
1063
+ if (
1064
+ props.clipboardEnabled &&
1065
+ command &&
1066
+ !event.altKey &&
1067
+ ["c", "v"].includes(event.key.toLowerCase())
1068
+ ) {
1069
+ flushPendingText();
1070
+ return;
1071
+ }
736
1072
  const key = computerKey(event);
737
1073
  if (!key) return;
738
1074
  event.preventDefault();
1075
+ flushPendingText();
1076
+ flushPendingClick();
739
1077
  enqueue({ type: "keyboard", action: "press", value: key }, null);
740
1078
  };
741
1079
 
1080
+ const copy = (event: ClipboardEvent<HTMLTextAreaElement>) => {
1081
+ if (!props.clipboardEnabled || !rawInputEnabled) return;
1082
+ event.preventDefault();
1083
+ flushPendingText();
1084
+ flushPendingClick();
1085
+ enqueue({ type: "clipboard", operation: "copy" }, null, async () => {
1086
+ const clipboard = await readClipboardRef.current();
1087
+ if (!clipboard.text) return;
1088
+ if (!(await copyTextToClipboard(clipboard.text))) {
1089
+ throw new Error("Computer text could not be copied to the local clipboard");
1090
+ }
1091
+ });
1092
+ };
1093
+
1094
+ const paste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
1095
+ if (!props.clipboardEnabled || !rawInputEnabled) return;
1096
+ event.preventDefault();
1097
+ flushPendingText();
1098
+ flushPendingClick();
1099
+ const text = event.clipboardData.getData("text/plain");
1100
+ enqueue({ type: "clipboard", operation: "write", text }, null, async () => {
1101
+ assertExactComputerClipboard(await readClipboardRef.current(), text);
1102
+ await actionRef.current({ type: "clipboard", operation: "paste" }, null);
1103
+ });
1104
+ };
1105
+
742
1106
  const input = (value: string) => {
743
- if (!value) return;
744
- enqueue({ type: "keyboard", action: "type", value }, null);
1107
+ if (!value || !rawInputEnabled) return;
1108
+ flushPendingClick();
1109
+ const pending = pendingTextRef.current;
1110
+ if (pending) {
1111
+ clearTimeout(pending.timer);
1112
+ pending.text += value;
1113
+ pending.timer = setTimeout(flushPendingText, 16);
1114
+ } else {
1115
+ pendingTextRef.current = {
1116
+ text: value,
1117
+ timer: setTimeout(flushPendingText, 16),
1118
+ };
1119
+ }
745
1120
  if (inputRef.current) inputRef.current.value = "";
746
1121
  };
747
1122
 
@@ -751,8 +1126,9 @@ function ComputerViewport(props: {
751
1126
  <canvas
752
1127
  ref={canvasRef}
753
1128
  className={cn(
754
- "absolute inset-0 m-auto max-h-full max-w-full touch-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-og-accent",
1129
+ "absolute inset-0 m-auto max-h-full max-w-full touch-none focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-og-accent",
755
1130
  !showCanvas && "invisible",
1131
+ !rawInputEnabled && "cursor-default",
756
1132
  )}
757
1133
  onPointerDown={pointerDown}
758
1134
  onPointerUp={pointerUp}
@@ -768,11 +1144,14 @@ function ComputerViewport(props: {
768
1144
  defaultValue=""
769
1145
  onInput={(event) => input(event.currentTarget.value)}
770
1146
  onKeyDown={keyDown}
1147
+ onCopy={copy}
1148
+ onPaste={paste}
771
1149
  className="pointer-events-none absolute left-1/2 top-1/2 size-px resize-none overflow-hidden opacity-0"
772
1150
  aria-label="Computer keyboard input"
773
1151
  autoCapitalize="off"
774
1152
  autoCorrect="off"
775
1153
  spellCheck={false}
1154
+ disabled={!rawInputEnabled}
776
1155
  />
777
1156
  {!showCanvas ? (
778
1157
  <ComputerViewportFallback
@@ -789,10 +1168,31 @@ function ComputerViewport(props: {
789
1168
  <LoaderCircleIcon className="size-3 animate-spin" /> Acting
790
1169
  </div>
791
1170
  ) : null}
1171
+ {showCanvas && !rawInputEnabled ? (
1172
+ <div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-2 rounded-full border border-white/10 bg-black/70 py-1 pl-3 pr-1 text-[11px] text-white/80 backdrop-blur">
1173
+ <span>Background view · use native controls</span>
1174
+ {props.target ? (
1175
+ <button
1176
+ type="button"
1177
+ disabled={props.mutating}
1178
+ onClick={() => enqueue({ type: "focus", targetId: props.target!.id }, null)}
1179
+ className="rounded-full bg-white/10 px-2 py-0.5 font-medium text-white transition hover:bg-white/20 disabled:opacity-50"
1180
+ >
1181
+ Control directly
1182
+ </button>
1183
+ ) : null}
1184
+ </div>
1185
+ ) : null}
792
1186
  </div>
793
1187
  );
794
1188
  }
795
1189
 
1190
+ function assertExactComputerClipboard(clipboard: ComputerClipboard, expected: string): void {
1191
+ if (clipboard.truncated || clipboard.text !== expected) {
1192
+ throw new Error("Computer clipboard did not accept the exact pasted text");
1193
+ }
1194
+ }
1195
+
796
1196
  function ComputerViewportFallback(props: {
797
1197
  observation: ComputerObservation | null;
798
1198
  machineLocked: boolean;
@@ -883,31 +1283,89 @@ function ComputerSemanticPanel(props: {
883
1283
  </p>
884
1284
  ) : (
885
1285
  <div className="space-y-0.5">
886
- {nodes.map((node) => {
887
- const action = semanticAction(node);
888
- return (
889
- <button
890
- key={node.ref}
891
- type="button"
892
- disabled={!action || props.mutating}
893
- onClick={() => {
894
- if (action) props.onAction(action);
895
- }}
896
- className="flex w-full items-start gap-2 rounded-og-sm px-2 py-1.5 text-left transition hover:bg-og-surface-2 disabled:cursor-default disabled:hover:bg-transparent"
897
- >
898
- <span className="mt-0.5 text-[10px] uppercase text-og-subtle">{node.role}</span>
899
- <span className="min-w-0 flex-1 truncate text-og-control text-og-fg">
900
- {node.name || semanticValue(node) || node.identifier || "Unnamed control"}
901
- </span>
902
- </button>
903
- );
904
- })}
1286
+ {nodes.map((node) => (
1287
+ <ComputerSemanticControl
1288
+ key={node.ref}
1289
+ node={node}
1290
+ mutating={props.mutating}
1291
+ onAction={props.onAction}
1292
+ />
1293
+ ))}
905
1294
  </div>
906
1295
  )}
907
1296
  </aside>
908
1297
  );
909
1298
  }
910
1299
 
1300
+ function ComputerSemanticControl(props: {
1301
+ node: InteractionSemanticNode;
1302
+ mutating: boolean;
1303
+ onAction: (action: ComputerAction) => void;
1304
+ }) {
1305
+ const { node } = props;
1306
+ const editable = node.actions.includes("set_value");
1307
+ const action = semanticAction(node);
1308
+ const observedValue = semanticValue(node) ?? "";
1309
+ const [value, setValue] = useState(() => observedValue);
1310
+
1311
+ useEffect(() => {
1312
+ setValue(observedValue);
1313
+ }, [node.ref, observedValue]);
1314
+
1315
+ if (editable) {
1316
+ return (
1317
+ <form
1318
+ className="rounded-og-sm px-2 py-1.5 hover:bg-og-surface-2"
1319
+ onSubmit={(event) => {
1320
+ event.preventDefault();
1321
+ props.onAction({
1322
+ type: "semantic",
1323
+ locator: { kind: "ref", ref: node.ref },
1324
+ action: "set_value",
1325
+ value,
1326
+ });
1327
+ }}
1328
+ >
1329
+ <label className="block truncate text-[10px] uppercase text-og-subtle">
1330
+ {node.name || node.identifier || node.role}
1331
+ </label>
1332
+ <div className="mt-1 flex gap-1">
1333
+ <input
1334
+ value={value}
1335
+ disabled={props.mutating}
1336
+ onChange={(event) => setValue(event.currentTarget.value)}
1337
+ className="min-w-0 flex-1 rounded-og-sm border border-og-border bg-og-surface-1 px-1.5 py-1 text-og-control text-og-fg outline-hidden focus:border-og-accent"
1338
+ aria-label={`Set ${node.name || node.identifier || node.role}`}
1339
+ />
1340
+ <button
1341
+ type="submit"
1342
+ disabled={props.mutating}
1343
+ className="rounded-og-sm border border-og-border bg-og-surface-1 px-2 text-og-control font-medium text-og-fg transition hover:bg-og-surface-2 disabled:opacity-50"
1344
+ >
1345
+ Set
1346
+ </button>
1347
+ </div>
1348
+ </form>
1349
+ );
1350
+ }
1351
+
1352
+ return (
1353
+ <button
1354
+ type="button"
1355
+ disabled={!action || props.mutating}
1356
+ onClick={() => {
1357
+ if (action) props.onAction(action);
1358
+ }}
1359
+ className="flex w-full items-start gap-2 rounded-og-sm px-2 py-1.5 text-left transition hover:bg-og-surface-2 disabled:cursor-default disabled:hover:bg-transparent"
1360
+ >
1361
+ <span className="mt-0.5 text-[10px] uppercase text-og-subtle">{node.role}</span>
1362
+ <span className="min-w-0 flex-1 truncate text-og-control text-og-fg">
1363
+ {node.name || semanticValue(node) || node.identifier || "Unnamed control"}
1364
+ </span>
1365
+ </button>
1366
+ );
1367
+ }
1368
+
911
1369
  function ComputerStatusBar(props: {
912
1370
  session: ComputerSession | null;
913
1371
  target: ComputerTarget | null;
@@ -933,7 +1391,7 @@ function ComputerStatusBar(props: {
933
1391
  {screen
934
1392
  ? "Full screen · input may move pointer and focus"
935
1393
  : props.session?.capabilities?.backgroundActions
936
- ? "Window · native actions can stay in the background"
1394
+ ? "Window · semantic controls stay in the background"
937
1395
  : (props.target?.kind ?? "Computer")}
938
1396
  </span>
939
1397
  {screen ? <MousePointer2Icon className="size-3" aria-hidden /> : null}
@@ -989,13 +1447,9 @@ function semanticAction(node: InteractionSemanticNode): ComputerAction | null {
989
1447
  action: "invoke",
990
1448
  };
991
1449
  }
992
- if (node.actions.includes("focus")) {
993
- return {
994
- type: "semantic",
995
- locator: { kind: "ref", ref: node.ref },
996
- action: "focus",
997
- };
998
- }
1450
+ // A row in the background-native controls panel must never become an
1451
+ // implicit foregrounding gesture. Focus remains available through the typed
1452
+ // SDK/tool action when the caller explicitly intends to take the shared seat.
999
1453
  return null;
1000
1454
  }
1001
1455
 
@@ -1040,7 +1494,13 @@ function computerPoint(
1040
1494
  };
1041
1495
  }
1042
1496
 
1043
- function computerKey(event: KeyboardEvent<HTMLTextAreaElement>): string | null {
1497
+ export function computerKey(
1498
+ event: Pick<
1499
+ KeyboardEvent<HTMLTextAreaElement>,
1500
+ "altKey" | "ctrlKey" | "key" | "metaKey" | "shiftKey"
1501
+ >,
1502
+ ): string | null {
1503
+ if (["Alt", "AltGraph", "Control", "Meta", "Shift"].includes(event.key)) return null;
1044
1504
  const special = new Set([
1045
1505
  "Enter",
1046
1506
  "Tab",
@@ -1080,6 +1540,43 @@ function isLiveComputer(session: ComputerSession): boolean {
1080
1540
  return !["ending", "ended", "failed", "lost"].includes(session.lifecycle);
1081
1541
  }
1082
1542
 
1543
+ function computerFailureMessage(session: ComputerSession): string | null {
1544
+ switch (session.failureCode) {
1545
+ case "machine_locked":
1546
+ return "Unlock the connected Mac, then try again.";
1547
+ case "controller_heartbeat_expired":
1548
+ return "The computer connection was lost. Try opening it again.";
1549
+ case null:
1550
+ return null;
1551
+ default:
1552
+ return "The computer could not be opened. Try again.";
1553
+ }
1554
+ }
1555
+
1556
+ function countInterventions(
1557
+ interventions: readonly InteractionIntervention[],
1558
+ ): Map<string, number> {
1559
+ const counts = new Map<string, number>();
1560
+ for (const intervention of interventions) {
1561
+ counts.set(intervention.resourceId, (counts.get(intervention.resourceId) ?? 0) + 1);
1562
+ }
1563
+ return counts;
1564
+ }
1565
+
1566
+ function interventionTitle(intervention: InteractionIntervention): string {
1567
+ switch (intervention.kind) {
1568
+ case "manual_login":
1569
+ return "Sign in needed";
1570
+ case "mfa":
1571
+ return "Verification needed";
1572
+ case "external_action":
1573
+ case "confirmation":
1574
+ return "Action needed";
1575
+ case "other":
1576
+ return "Computer needs your help";
1577
+ }
1578
+ }
1579
+
1083
1580
  function platformLabel(session: ComputerSession): string {
1084
1581
  switch (session.platform) {
1085
1582
  case "macos":