@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.
- package/README.md +48 -0
- package/dist/artifacts.d.ts +1 -1
- package/dist/artifacts.js +43 -14
- package/dist/artifacts.js.map +1 -1
- package/dist/{chunk-ROIRTM37.js → chunk-5UQPNLIO.js} +6 -6
- package/dist/{chunk-JR3QFY6B.js → chunk-6KLX2RH5.js} +49 -2
- package/dist/chunk-6KLX2RH5.js.map +1 -0
- package/dist/{chunk-FWS3KC3Z.js → chunk-B6WPZSTT.js} +104 -104
- package/dist/{chunk-FWS3KC3Z.js.map → chunk-B6WPZSTT.js.map} +1 -1
- package/dist/{chunk-HDBG7XGO.js → chunk-C5ZPSSS5.js} +50 -1
- package/dist/chunk-C5ZPSSS5.js.map +1 -0
- package/dist/{chunk-X4S56Y2H.js → chunk-HA46CYQK.js} +135 -11
- package/dist/chunk-HA46CYQK.js.map +1 -0
- package/dist/{chunk-724BW4TW.js → chunk-IGC6A2ZI.js} +2 -2
- package/dist/chunk-LLOLUHVG.js +5937 -0
- package/dist/chunk-LLOLUHVG.js.map +1 -0
- package/dist/{chunk-WYA65Y3F.js → chunk-MKPIAGVL.js} +13 -3
- package/dist/chunk-MKPIAGVL.js.map +1 -0
- package/dist/chunk-R3AZOAB5.js +933 -0
- package/dist/chunk-R3AZOAB5.js.map +1 -0
- package/dist/{chunk-23EJ676W.js → chunk-XBZWOM2K.js} +9 -4
- package/dist/chunk-XBZWOM2K.js.map +1 -0
- package/dist/{chunk-JAGDUCZQ.js → chunk-Y7OEDUJ5.js} +17 -13
- package/dist/chunk-Y7OEDUJ5.js.map +1 -0
- package/dist/client.d.ts +6 -4
- package/dist/components/artifacts/index.d.ts +1 -0
- package/dist/components/artifacts/published-html-artifact-frame.d.ts +14 -0
- package/dist/components/browser-input.d.ts +7 -0
- package/dist/components/browser-viewer.d.ts +5 -4
- package/dist/components/computer-viewer.d.ts +2 -1
- package/dist/components/desktop-viewer.d.ts +26 -2
- package/dist/components/interaction-intervention-banner.d.ts +11 -0
- package/dist/components/sandbox-terminal.d.ts +4 -1
- package/dist/components/sandbox-workspace.d.ts +3 -0
- package/dist/composer.js +6 -6
- package/dist/hooks/use-auth-runs.d.ts +32 -0
- package/dist/hooks/use-browser-downloads.d.ts +25 -0
- package/dist/hooks/use-browser-identities.d.ts +5 -1
- package/dist/hooks/use-browser-session.d.ts +2 -1
- package/dist/hooks/use-browser-sessions.d.ts +1 -1
- package/dist/hooks/use-channels.d.ts +26 -0
- package/dist/hooks/use-computer-frame-stream.d.ts +1 -1
- package/dist/hooks/use-computer-session.d.ts +2 -1
- package/dist/hooks/use-computer-sessions.d.ts +1 -1
- package/dist/hooks/use-desktop-stream.d.ts +4 -0
- package/dist/hooks/use-interaction-interventions.d.ts +27 -0
- package/dist/hooks/use-interaction-invalidation.d.ts +19 -0
- package/dist/hooks/use-network-routes.d.ts +25 -0
- package/dist/hooks/use-site-auth-connections.d.ts +25 -0
- package/dist/hooks/use-supergrok-accounts.d.ts +26 -0
- package/dist/hooks/use-terminal-stream.d.ts +3 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1220 -1545
- package/dist/index.js.map +1 -1
- package/dist/interaction.d.ts +13 -3
- package/dist/interaction.js +520 -6
- package/dist/interaction.js.map +1 -1
- package/dist/interactive-workbench-viewers-TOHBXJEE.js +15 -0
- package/dist/lib/host-platform.d.ts +5 -0
- package/dist/lib/relay-wire.d.ts +20 -1
- package/dist/machines.js +3 -3
- package/dist/model-policy.d.ts +1 -1
- package/dist/model-policy.js +1 -1
- package/dist/provider.d.ts +3 -2
- package/dist/realtime/realtime-control.d.ts +2 -2
- package/dist/realtime.js +36 -9
- package/dist/realtime.js.map +1 -1
- package/dist/session-context.d.ts +10 -2
- package/dist/session-ui.js +3 -3
- package/dist/session.js +11 -11
- package/dist/timeline/types.d.ts +5 -0
- package/package.json +2 -2
- package/src/artifacts.ts +3 -0
- package/src/client.ts +73 -3
- package/src/components/artifacts/index.ts +5 -0
- package/src/components/artifacts/published-html-artifact-frame.tsx +38 -0
- package/src/components/browser-input.ts +65 -0
- package/src/components/browser-viewer.tsx +1287 -177
- package/src/components/computer-viewer.tsx +611 -114
- package/src/components/desktop-viewer.tsx +114 -1
- package/src/components/interaction-intervention-banner.tsx +99 -0
- package/src/components/machines/machine-detail.tsx +1 -1
- package/src/components/model-policy-picker.tsx +15 -0
- package/src/components/sandbox-terminal.tsx +24 -7
- package/src/components/sandbox-workspace.tsx +42 -4
- package/src/components/workspace-dock.tsx +59 -5
- package/src/hooks/use-attached-browsers.ts +13 -11
- package/src/hooks/use-auth-runs.ts +278 -0
- package/src/hooks/use-browser-downloads.ts +218 -0
- package/src/hooks/use-browser-frame-stream.ts +168 -59
- package/src/hooks/use-browser-identities.ts +47 -6
- package/src/hooks/use-browser-session.ts +94 -27
- package/src/hooks/use-browser-sessions.ts +19 -14
- package/src/hooks/use-channels.ts +99 -0
- package/src/hooks/use-computer-frame-stream.ts +184 -61
- package/src/hooks/use-computer-session.ts +22 -2
- package/src/hooks/use-computer-sessions.ts +19 -13
- package/src/hooks/use-desktop-stream.ts +39 -7
- package/src/hooks/use-interaction-interventions.ts +242 -0
- package/src/hooks/use-interaction-invalidation.ts +66 -0
- package/src/hooks/use-machine-chip.ts +13 -3
- package/src/hooks/use-network-routes.ts +214 -0
- package/src/hooks/use-site-auth-connections.ts +226 -0
- package/src/hooks/use-supergrok-accounts.ts +81 -0
- package/src/hooks/use-terminal-stream.ts +288 -55
- package/src/index.ts +8 -0
- package/src/interaction.ts +30 -0
- package/src/lib/host-platform.ts +10 -0
- package/src/lib/relay-wire.ts +43 -4
- package/src/lib/terminal-capability.ts +1 -1
- package/src/model-policy.ts +19 -4
- package/src/provider.tsx +133 -1
- package/src/realtime/realtime-control.tsx +34 -6
- package/src/session-context.ts +66 -1
- package/src/timeline/activity-rail.tsx +6 -0
- package/src/timeline/projection.ts +58 -0
- package/src/timeline/types.ts +2 -0
- package/styles/compiled.css +64 -6
- package/dist/chunk-23EJ676W.js.map +0 -1
- package/dist/chunk-6BBWWOOU.js +0 -82
- package/dist/chunk-6BBWWOOU.js.map +0 -1
- package/dist/chunk-7I2KB3OK.js +0 -3895
- package/dist/chunk-7I2KB3OK.js.map +0 -1
- package/dist/chunk-HDBG7XGO.js.map +0 -1
- package/dist/chunk-JAGDUCZQ.js.map +0 -1
- package/dist/chunk-JR3QFY6B.js.map +0 -1
- package/dist/chunk-WYA65Y3F.js.map +0 -1
- package/dist/chunk-X4S56Y2H.js.map +0 -1
- package/dist/interactive-workbench-viewers-KHNOHWQE.js +0 -13
- /package/dist/{chunk-ROIRTM37.js.map → chunk-5UQPNLIO.js.map} +0 -0
- /package/dist/{chunk-724BW4TW.js.map → chunk-IGC6A2ZI.js.map} +0 -0
- /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
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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: {
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
"
|
|
407
|
-
session.
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
416
|
-
|
|
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
|
-
{
|
|
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 &&
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
if (
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
if (
|
|
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
|
-
|
|
581
|
-
|
|
857
|
+
mountedRef.current = false;
|
|
858
|
+
queuedFrameRef.current = null;
|
|
582
859
|
};
|
|
583
|
-
}, [
|
|
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(
|
|
594
|
-
|
|
595
|
-
.
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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 ·
|
|
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
|
-
|
|
993
|
-
|
|
994
|
-
|
|
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(
|
|
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":
|