@opengeni/react 0.48.0 → 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 +138 -2
- package/dist/artifacts-document.d.ts +3 -0
- package/dist/artifacts-document.js +17 -0
- package/dist/artifacts-document.js.map +1 -0
- package/dist/artifacts-presentation.d.ts +3 -0
- package/dist/artifacts-presentation.js +17 -0
- package/dist/artifacts-presentation.js.map +1 -0
- package/dist/artifacts-spreadsheet.d.ts +3 -0
- package/dist/artifacts-spreadsheet.js +17 -0
- package/dist/artifacts-spreadsheet.js.map +1 -0
- package/dist/artifacts.d.ts +2 -0
- package/dist/artifacts.js +199 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/{chunk-NJ7M2B6Z.js → chunk-5UQPNLIO.js} +16 -8
- package/dist/{chunk-NJ7M2B6Z.js.map → chunk-5UQPNLIO.js.map} +1 -1
- package/dist/{chunk-LHLYWG5R.js → chunk-6KLX2RH5.js} +461 -38
- package/dist/chunk-6KLX2RH5.js.map +1 -0
- package/dist/chunk-7WCLAEFV.js +2828 -0
- package/dist/chunk-7WCLAEFV.js.map +1 -0
- package/dist/{chunk-I4MPFDRP.js → chunk-B6WPZSTT.js} +104 -104
- package/dist/{chunk-I4MPFDRP.js.map → chunk-B6WPZSTT.js.map} +1 -1
- package/dist/{chunk-EKZH6IDG.js → chunk-C5ZPSSS5.js} +111 -1
- package/dist/chunk-C5ZPSSS5.js.map +1 -0
- package/dist/{chunk-OL6QVXGF.js → chunk-HA46CYQK.js} +152 -8
- package/dist/chunk-HA46CYQK.js.map +1 -0
- package/dist/chunk-HUIKIDM5.js +2148 -0
- package/dist/chunk-HUIKIDM5.js.map +1 -0
- package/dist/{chunk-FWXGSNXT.js → chunk-IGC6A2ZI.js} +585 -34
- package/dist/chunk-IGC6A2ZI.js.map +1 -0
- package/dist/chunk-JVYQRRC4.js +53 -0
- package/dist/chunk-JVYQRRC4.js.map +1 -0
- package/dist/chunk-KCGXPP5V.js +157 -0
- package/dist/chunk-KCGXPP5V.js.map +1 -0
- 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-PEI6RANI.js +3234 -0
- package/dist/chunk-PEI6RANI.js.map +1 -0
- package/dist/chunk-R3AZOAB5.js +933 -0
- package/dist/chunk-R3AZOAB5.js.map +1 -0
- package/dist/{chunk-O4VXOBGF.js → chunk-WSK7G5LE.js} +70 -2
- package/dist/chunk-WSK7G5LE.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-XCNTXWBB.js → chunk-Y7OEDUJ5.js} +856 -338
- package/dist/chunk-Y7OEDUJ5.js.map +1 -0
- package/dist/client.d.ts +10 -2
- package/dist/clipboard.d.ts +1 -0
- package/dist/clipboard.js +7 -0
- package/dist/clipboard.js.map +1 -0
- package/dist/commands/index.d.ts +3 -3
- package/dist/commands/registry.d.ts +1 -1
- package/dist/commands/types.d.ts +1 -1
- package/dist/components/approval-surface.d.ts +2 -2
- package/dist/components/artifacts/artifact-surface.d.ts +18 -0
- package/dist/components/artifacts/document-editor.d.ts +172 -0
- package/dist/components/artifacts/editable-artifact-ui.d.ts +13 -0
- package/dist/components/artifacts/editable-artifact-workbench.d.ts +49 -0
- package/dist/components/artifacts/editable-document.d.ts +24 -0
- package/dist/components/artifacts/editable-presentation.d.ts +24 -0
- package/dist/components/artifacts/editable-spreadsheet.d.ts +32 -0
- package/dist/components/artifacts/index.d.ts +9 -0
- package/dist/components/artifacts/presentation-editor.d.ts +297 -0
- package/dist/components/artifacts/published-html-artifact-frame.d.ts +14 -0
- package/dist/components/artifacts/spreadsheet-canvas.d.ts +120 -0
- package/dist/components/artifacts/spreadsheet-grid.d.ts +113 -0
- package/dist/components/browser-input.d.ts +7 -0
- package/dist/components/browser-viewer.d.ts +30 -0
- package/dist/components/chat-composer.d.ts +8 -8
- package/dist/components/code-editor.d.ts +25 -3
- package/dist/components/command-palette.d.ts +2 -2
- package/dist/components/composer-transcription-control.d.ts +2 -2
- package/dist/components/composer.d.ts +17 -17
- package/dist/components/computer-viewer.d.ts +24 -0
- package/dist/components/copy-button.d.ts +2 -2
- package/dist/components/desktop-viewer.d.ts +50 -22
- package/dist/components/diff-view.d.ts +1 -1
- package/dist/components/enrollment-consent.d.ts +1 -1
- package/dist/components/enrollment-device-flow.d.ts +1 -1
- package/dist/components/file-browser.d.ts +2 -2
- package/dist/components/fleet-tile.d.ts +1 -1
- package/dist/components/generated-video-player.d.ts +10 -0
- package/dist/components/human-input-form.d.ts +1 -1
- package/dist/components/human-input-surface.d.ts +2 -2
- package/dist/components/interaction-intervention-banner.d.ts +11 -0
- package/dist/components/interactive-workbench-viewers.d.ts +2 -0
- package/dist/components/machine-card.d.ts +2 -2
- package/dist/components/machine-dock-bar.d.ts +3 -3
- package/dist/components/machine-health-pill.d.ts +2 -2
- package/dist/components/machine-input-display.d.ts +1 -1
- package/dist/components/machine-metrics.d.ts +2 -2
- package/dist/components/machine-status-pill.d.ts +4 -4
- package/dist/components/machines/health.d.ts +1 -1
- package/dist/components/machines/machine-detail.d.ts +3 -3
- package/dist/components/machines/metric-history-chart.d.ts +1 -1
- package/dist/components/machines/metric-sparkline.d.ts +2 -2
- package/dist/components/machines/series.d.ts +2 -2
- package/dist/components/machines-dashboard.d.ts +2 -2
- package/dist/components/markdown.d.ts +1 -1
- package/dist/components/message-timeline.d.ts +12 -5
- package/dist/components/model-picker.d.ts +2 -2
- package/dist/components/model-policy-picker.d.ts +7 -7
- package/dist/components/pierre-diff.d.ts +1 -1
- package/dist/components/pierre-file.d.ts +1 -1
- package/dist/components/queue-draft-policy.d.ts +1 -1
- package/dist/components/queue-surface-implementation.d.ts +3 -3
- package/dist/components/queue-surface-state.d.ts +4 -4
- package/dist/components/queue-surface.d.ts +5 -5
- package/dist/components/sandbox-files.d.ts +3 -3
- package/dist/components/sandbox-terminal.d.ts +24 -4
- package/dist/components/sandbox-workspace.d.ts +28 -9
- package/dist/components/session-chrome.d.ts +4 -4
- package/dist/components/session-status.d.ts +2 -2
- package/dist/components/timeline-annotation-selection.d.ts +9 -0
- package/dist/components/timeline-annotations-dialog.d.ts +16 -0
- package/dist/components/timeline-annotations.d.ts +13 -0
- package/dist/components/tooltip.d.ts +4 -4
- package/dist/components/user-message-body.d.ts +2 -2
- package/dist/components/workbench-changes.d.ts +2 -2
- package/dist/components/workspace-dock.d.ts +1 -1
- package/dist/composer.d.ts +11 -11
- package/dist/composer.js +7 -7
- package/dist/hooks/internal.d.ts +1 -1
- package/dist/hooks/use-attached-browsers.d.ts +21 -0
- package/dist/hooks/use-auth-runs.d.ts +32 -0
- package/dist/hooks/use-available-models.d.ts +2 -2
- package/dist/hooks/use-billing-usage.d.ts +1 -1
- package/dist/hooks/use-browser-downloads.d.ts +25 -0
- package/dist/hooks/use-browser-frame-stream.d.ts +28 -0
- package/dist/hooks/use-browser-identities.d.ts +30 -0
- package/dist/hooks/use-browser-session.d.ts +29 -0
- package/dist/hooks/use-browser-sessions.d.ts +32 -0
- package/dist/hooks/use-channels.d.ts +26 -0
- package/dist/hooks/use-codex-accounts.d.ts +2 -2
- package/dist/hooks/use-composer.d.ts +36 -9
- package/dist/hooks/use-computer-frame-stream.d.ts +24 -0
- package/dist/hooks/use-computer-session.d.ts +29 -0
- package/dist/hooks/use-computer-sessions.d.ts +28 -0
- package/dist/hooks/use-desktop-stream.d.ts +5 -1
- package/dist/hooks/use-environments.d.ts +1 -1
- package/dist/hooks/use-file-attachments.d.ts +1 -1
- package/dist/hooks/use-goal.d.ts +2 -2
- package/dist/hooks/use-human-input.d.ts +2 -2
- package/dist/hooks/use-interaction-interventions.d.ts +27 -0
- package/dist/hooks/use-interaction-invalidation.d.ts +19 -0
- package/dist/hooks/use-last-started-turn-policy.d.ts +2 -2
- package/dist/hooks/use-machine-chip.d.ts +2 -2
- package/dist/hooks/use-machines.d.ts +4 -4
- package/dist/hooks/use-network-routes.d.ts +25 -0
- package/dist/hooks/use-packs.d.ts +7 -2
- package/dist/hooks/use-rigs.d.ts +1 -1
- package/dist/hooks/use-sandbox-files.d.ts +6 -4
- package/dist/hooks/use-sandbox-git.d.ts +6 -5
- package/dist/hooks/use-sandbox-terminal.d.ts +1 -1
- package/dist/hooks/use-scheduled-tasks.d.ts +1 -1
- package/dist/hooks/use-session-capabilities.d.ts +5 -4
- package/dist/hooks/use-session-control.d.ts +1 -1
- package/dist/hooks/use-session-events.d.ts +2 -2
- package/dist/hooks/use-session-lineage.d.ts +1 -1
- package/dist/hooks/use-session-mcp-approval-policy.d.ts +2 -2
- package/dist/hooks/use-session.d.ts +2 -2
- package/dist/hooks/use-site-auth-connections.d.ts +25 -0
- package/dist/hooks/use-slash-commands.d.ts +1 -1
- package/dist/hooks/use-supergrok-accounts.d.ts +26 -0
- package/dist/hooks/use-terminal-stream.d.ts +11 -2
- package/dist/hooks/use-turn-queue.d.ts +2 -2
- package/dist/hooks/use-video-artifact-playback.d.ts +9 -0
- package/dist/hooks/use-voice-input.d.ts +1 -1
- package/dist/hooks/use-workspace-capture.d.ts +1 -1
- package/dist/hooks/use-workspace-edit.d.ts +1 -1
- package/dist/hooks/use-workspace-sessions.d.ts +1 -1
- package/dist/hooks/use-workspaces.d.ts +1 -1
- package/dist/index.d.ts +162 -155
- package/dist/index.js +1822 -1703
- package/dist/index.js.map +1 -1
- package/dist/interaction.d.ts +33 -0
- package/dist/interaction.js +550 -0
- package/dist/interaction.js.map +1 -0
- package/dist/interactive-workbench-viewers-TOHBXJEE.js +15 -0
- package/dist/interactive-workbench-viewers-TOHBXJEE.js.map +1 -0
- package/dist/lib/host-platform.d.ts +5 -0
- package/dist/lib/relay-wire.d.ts +24 -1
- package/dist/lib/sandbox-liveness.d.ts +9 -0
- package/dist/lib/terminal-capability.d.ts +5 -0
- package/dist/lib/xterm-theme.d.ts +1 -1
- package/dist/machines.d.ts +30 -30
- package/dist/machines.js +2 -2
- package/dist/model-policy.d.ts +1 -1
- package/dist/model-policy.js +1 -1
- package/dist/provider.d.ts +6 -5
- package/dist/{queue-surface-implementation-U5JFG7HT.js → queue-surface-implementation-2KVJTYXH.js} +100 -48
- package/dist/queue-surface-implementation-2KVJTYXH.js.map +1 -0
- package/dist/realtime/dropdown-menu.d.ts +8 -8
- package/dist/realtime/realtime-control.d.ts +8 -8
- package/dist/realtime.d.ts +4 -4
- package/dist/realtime.js +38 -11
- package/dist/realtime.js.map +1 -1
- package/dist/session-context.d.ts +28 -2
- package/dist/session-ui.d.ts +14 -14
- package/dist/session-ui.js +5 -4
- package/dist/session.d.ts +28 -28
- package/dist/session.js +10 -10
- package/dist/timeline/activity-rail.d.ts +5 -3
- package/dist/timeline/disclosure-context.d.ts +1 -1
- package/dist/timeline/fleet-decision-projection.d.ts +1 -1
- package/dist/timeline/fleet-decision-row.d.ts +3 -3
- package/dist/timeline/index.d.ts +19 -19
- package/dist/timeline/parsers.d.ts +4 -1
- package/dist/timeline/projection.d.ts +3 -3
- package/dist/timeline/registry.d.ts +8 -2
- package/dist/timeline/screenshot-lightbox.d.ts +2 -2
- package/dist/timeline/shared.d.ts +21 -10
- package/dist/timeline/tool-diff.d.ts +2 -2
- package/dist/timeline/tool-renderers.d.ts +1 -1
- package/dist/timeline/turn-summary.d.ts +3 -3
- package/dist/timeline/types.d.ts +44 -3
- package/dist/timeline-annotation-selection-3L7LHGG2.js +187 -0
- package/dist/timeline-annotation-selection-3L7LHGG2.js.map +1 -0
- package/dist/timeline-annotations-dialog-U7EVLR75.js +196 -0
- package/dist/timeline-annotations-dialog-U7EVLR75.js.map +1 -0
- package/package.json +36 -7
- package/src/artifacts-document.ts +31 -0
- package/src/artifacts-presentation.ts +36 -0
- package/src/artifacts-spreadsheet.ts +21 -0
- package/src/artifacts.ts +89 -0
- package/src/client.ts +157 -1
- package/src/clipboard.ts +2 -0
- package/src/components/artifacts/artifact-surface.tsx +93 -0
- package/src/components/artifacts/document-editor.tsx +2255 -0
- package/src/components/artifacts/editable-artifact-ui.tsx +89 -0
- package/src/components/artifacts/editable-artifact-workbench.tsx +203 -0
- package/src/components/artifacts/editable-document.tsx +657 -0
- package/src/components/artifacts/editable-presentation.tsx +1020 -0
- package/src/components/artifacts/editable-spreadsheet.tsx +733 -0
- package/src/components/artifacts/index.ts +104 -0
- package/src/components/artifacts/presentation-editor.tsx +3148 -0
- package/src/components/artifacts/published-html-artifact-frame.tsx +38 -0
- package/src/components/artifacts/spreadsheet-canvas.ts +799 -0
- package/src/components/artifacts/spreadsheet-grid.tsx +2129 -0
- package/src/components/browser-input.ts +65 -0
- package/src/components/browser-viewer.tsx +2702 -0
- package/src/components/chat-composer.tsx +13 -0
- package/src/components/code-editor.tsx +89 -30
- package/src/components/composer-transcription-control.tsx +10 -6
- package/src/components/computer-viewer.tsx +1636 -0
- package/src/components/desktop-viewer.tsx +215 -74
- package/src/components/generated-video-player.tsx +89 -0
- package/src/components/interaction-intervention-banner.tsx +99 -0
- package/src/components/interactive-workbench-viewers.ts +2 -0
- package/src/components/machine-input-display.ts +3 -0
- package/src/components/machines/machine-detail.tsx +1 -1
- package/src/components/message-timeline.tsx +289 -32
- package/src/components/model-policy-picker.tsx +15 -0
- package/src/components/queue-surface-implementation.tsx +116 -60
- package/src/components/sandbox-files.tsx +2 -2
- package/src/components/sandbox-terminal.tsx +128 -46
- package/src/components/sandbox-workspace.tsx +256 -90
- package/src/components/session-chrome.tsx +53 -13
- package/src/components/timeline-annotation-selection.tsx +221 -0
- package/src/components/timeline-annotations-dialog.tsx +213 -0
- package/src/components/timeline-annotations.tsx +84 -0
- package/src/components/workbench-changes.tsx +188 -31
- package/src/components/workspace-dock.tsx +59 -5
- package/src/hooks/use-attached-browsers.ts +154 -0
- 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 +425 -0
- package/src/hooks/use-browser-identities.ts +266 -0
- package/src/hooks/use-browser-session.ts +543 -0
- package/src/hooks/use-browser-sessions.ts +286 -0
- package/src/hooks/use-channels.ts +99 -0
- package/src/hooks/use-composer.ts +769 -24
- package/src/hooks/use-computer-frame-stream.ts +434 -0
- package/src/hooks/use-computer-session.ts +462 -0
- package/src/hooks/use-computer-sessions.ts +251 -0
- 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 +15 -3
- package/src/hooks/use-machines.ts +33 -3
- package/src/hooks/use-network-routes.ts +214 -0
- package/src/hooks/use-packs.ts +51 -0
- package/src/hooks/use-sandbox-files.ts +98 -55
- package/src/hooks/use-sandbox-git.ts +93 -38
- package/src/hooks/use-sandbox-terminal.ts +3 -2
- package/src/hooks/use-session-capabilities.ts +59 -46
- 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 +359 -47
- package/src/hooks/use-video-artifact-playback.ts +28 -0
- package/src/hooks/use-workspace-edit.ts +2 -1
- package/src/index.ts +18 -0
- package/src/interaction.ts +101 -0
- package/src/lib/host-platform.ts +10 -0
- package/src/lib/relay-wire.ts +47 -4
- package/src/lib/sandbox-liveness.ts +11 -0
- package/src/lib/terminal-capability.ts +10 -0
- 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 +156 -1
- package/src/timeline/activity-rail.tsx +25 -3
- package/src/timeline/index.ts +3 -0
- package/src/timeline/parsers.ts +14 -1
- package/src/timeline/projection.ts +269 -14
- package/src/timeline/registry.ts +13 -1
- package/src/timeline/screenshot-lightbox.tsx +19 -9
- package/src/timeline/shared.tsx +56 -8
- package/src/timeline/tool-renderers.tsx +303 -50
- package/src/timeline/types.ts +57 -3
- package/styles/compiled.css +312 -21
- package/styles/index.css +15 -0
- package/styles/responsive.css +13 -1
- package/dist/chunk-23EJ676W.js.map +0 -1
- package/dist/chunk-EKZH6IDG.js.map +0 -1
- package/dist/chunk-FWXGSNXT.js.map +0 -1
- package/dist/chunk-LHLYWG5R.js.map +0 -1
- package/dist/chunk-O4VXOBGF.js.map +0 -1
- package/dist/chunk-OL6QVXGF.js.map +0 -1
- package/dist/chunk-WYA65Y3F.js.map +0 -1
- package/dist/chunk-XCNTXWBB.js.map +0 -1
- package/dist/fleet-decision-projection-SLNZMS42.js +0 -241
- package/dist/fleet-decision-projection-SLNZMS42.js.map +0 -1
- package/dist/queue-surface-implementation-U5JFG7HT.js.map +0 -1
|
@@ -0,0 +1,1636 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ComputerAction,
|
|
3
|
+
ComputerClipboard,
|
|
4
|
+
ComputerFrame,
|
|
5
|
+
ComputerObservation,
|
|
6
|
+
ComputerSession,
|
|
7
|
+
ComputerTarget,
|
|
8
|
+
InteractionIntervention,
|
|
9
|
+
InteractionSemanticNode,
|
|
10
|
+
} from "@opengeni/sdk/interaction";
|
|
11
|
+
import type { DesktopStreamCapability } from "@opengeni/sdk";
|
|
12
|
+
import {
|
|
13
|
+
ChevronDownIcon,
|
|
14
|
+
CircleAlertIcon,
|
|
15
|
+
KeyboardIcon,
|
|
16
|
+
LoaderCircleIcon,
|
|
17
|
+
LockKeyholeIcon,
|
|
18
|
+
MonitorIcon,
|
|
19
|
+
MousePointer2Icon,
|
|
20
|
+
PanelsTopLeftIcon,
|
|
21
|
+
PlusIcon,
|
|
22
|
+
RefreshCwIcon,
|
|
23
|
+
RotateCcwIcon,
|
|
24
|
+
} from "lucide-react";
|
|
25
|
+
import {
|
|
26
|
+
type ClipboardEvent,
|
|
27
|
+
type KeyboardEvent,
|
|
28
|
+
type MouseEvent,
|
|
29
|
+
type PointerEvent,
|
|
30
|
+
type ReactNode,
|
|
31
|
+
type WheelEvent,
|
|
32
|
+
useCallback,
|
|
33
|
+
useEffect,
|
|
34
|
+
useMemo,
|
|
35
|
+
useRef,
|
|
36
|
+
useState,
|
|
37
|
+
} from "react";
|
|
38
|
+
import {
|
|
39
|
+
type ComputerFrameWebSocketFactory,
|
|
40
|
+
useComputerFrameStream,
|
|
41
|
+
} from "../hooks/use-computer-frame-stream";
|
|
42
|
+
import { useComputerSession } from "../hooks/use-computer-session";
|
|
43
|
+
import { useComputerSessions } from "../hooks/use-computer-sessions";
|
|
44
|
+
import { useInteractionInterventions } from "../hooks/use-interaction-interventions";
|
|
45
|
+
import { cn } from "../lib/cn";
|
|
46
|
+
import { copyTextToClipboard } from "../lib/clipboard";
|
|
47
|
+
import type { EmbeddedComputerInteractionClientOverride } from "../session-context";
|
|
48
|
+
import { InteractionInterventionBanner } from "./interaction-intervention-banner";
|
|
49
|
+
import { DesktopViewer } from "./desktop-viewer";
|
|
50
|
+
|
|
51
|
+
export type ComputerViewerNotification = { kind: "error" | "info"; message: string };
|
|
52
|
+
|
|
53
|
+
export type ComputerViewerProps = EmbeddedComputerInteractionClientOverride & {
|
|
54
|
+
/** Selected OpenGeni agent/session. Peer ComputerSessions remain visible. */
|
|
55
|
+
sessionId: string;
|
|
56
|
+
enabled?: boolean | undefined;
|
|
57
|
+
className?: string | undefined;
|
|
58
|
+
onNotify?: ((notification: ComputerViewerNotification) => void) | undefined;
|
|
59
|
+
/** Tests/demos only. Production uses the browser's native WebSocket. */
|
|
60
|
+
webSocketFactory?: ComputerFrameWebSocketFactory | undefined;
|
|
61
|
+
renderEmpty?: ((create: () => void, creating: boolean) => ReactNode) | undefined;
|
|
62
|
+
/** Host navigation request for one exact ComputerSession. */
|
|
63
|
+
requestedComputerSessionId?: string | null | undefined;
|
|
64
|
+
requestedComputerRequestId?: string | number | null | undefined;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type ComputerSelection = { sessionId: string; pinned: boolean } | null;
|
|
68
|
+
type PointerStart = { x: number; y: number; pointerId: number; frame: ComputerFrame };
|
|
69
|
+
|
|
70
|
+
/** Full ComputerSession surface: workspace discovery, peer switching, app/window/
|
|
71
|
+
* screen navigation, semantic controls, exact-frame input, and live media. */
|
|
72
|
+
export function ComputerViewer({
|
|
73
|
+
sessionId,
|
|
74
|
+
enabled = true,
|
|
75
|
+
className,
|
|
76
|
+
onNotify,
|
|
77
|
+
webSocketFactory,
|
|
78
|
+
renderEmpty,
|
|
79
|
+
requestedComputerSessionId,
|
|
80
|
+
requestedComputerRequestId,
|
|
81
|
+
...override
|
|
82
|
+
}: ComputerViewerProps) {
|
|
83
|
+
const registry = useComputerSessions({ ...override, sessionId, enabled });
|
|
84
|
+
const createSession = registry.create;
|
|
85
|
+
const refreshRegistry = registry.refresh;
|
|
86
|
+
const liveRelevant = useMemo(
|
|
87
|
+
() => registry.relevantSessions.filter((session) => isLiveComputer(session)),
|
|
88
|
+
[registry.relevantSessions],
|
|
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
|
+
);
|
|
108
|
+
const [selection, setSelection] = useState<ComputerSelection>(null);
|
|
109
|
+
const interventions = useInteractionInterventions({
|
|
110
|
+
...override,
|
|
111
|
+
enabled,
|
|
112
|
+
resourceKind: "computer_session",
|
|
113
|
+
});
|
|
114
|
+
const [creating, setCreating] = useState(false);
|
|
115
|
+
const [showControls, setShowControls] = useState(false);
|
|
116
|
+
const previousSessionIdRef = useRef(sessionId);
|
|
117
|
+
const handledRequestRef = useRef<string | null>(null);
|
|
118
|
+
const seenInterventionIdsRef = useRef(new Set<string>());
|
|
119
|
+
|
|
120
|
+
const notifyError = useCallback(
|
|
121
|
+
(cause: unknown, fallback: string) => {
|
|
122
|
+
onNotify?.({
|
|
123
|
+
kind: "error",
|
|
124
|
+
message: cause instanceof Error ? cause.message : fallback,
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
[onNotify],
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (previousSessionIdRef.current === sessionId) return;
|
|
132
|
+
previousSessionIdRef.current = sessionId;
|
|
133
|
+
handledRequestRef.current = null;
|
|
134
|
+
setSelection(null);
|
|
135
|
+
}, [sessionId]);
|
|
136
|
+
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
const requested = requestedComputerSessionId
|
|
139
|
+
? (liveSessions.find((session) => session.id === requestedComputerSessionId) ?? null)
|
|
140
|
+
: null;
|
|
141
|
+
const requestKey = requested
|
|
142
|
+
? `${requestedComputerRequestId ?? "default"}:${requested.id}`
|
|
143
|
+
: null;
|
|
144
|
+
if (requested && requestKey && handledRequestRef.current !== requestKey) {
|
|
145
|
+
handledRequestRef.current = requestKey;
|
|
146
|
+
setSelection({ sessionId: requested.id, pinned: true });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const selectedStillLive = liveSessions.some((session) => session.id === selection?.sessionId);
|
|
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;
|
|
156
|
+
if (!preferred) {
|
|
157
|
+
if (selection) setSelection(null);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (!selectedStillLive || !selection?.pinned) {
|
|
161
|
+
if (selection?.sessionId !== preferred.id || selection.pinned) {
|
|
162
|
+
setSelection({ sessionId: preferred.id, pinned: false });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}, [liveSessions, relevant, requestedComputerRequestId, requestedComputerSessionId, selection]);
|
|
166
|
+
|
|
167
|
+
const selectedRegistrySession = useMemo(
|
|
168
|
+
() => liveSessions.find((session) => session.id === selection?.sessionId) ?? null,
|
|
169
|
+
[liveSessions, selection?.sessionId],
|
|
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
|
+
);
|
|
182
|
+
const controllerReady = selectedRegistrySession?.lifecycle === "active";
|
|
183
|
+
const computer = useComputerSession({
|
|
184
|
+
...override,
|
|
185
|
+
computerSessionId: selection?.sessionId ?? null,
|
|
186
|
+
enabled: enabled && selection !== null && controllerReady,
|
|
187
|
+
});
|
|
188
|
+
const act = computer.act;
|
|
189
|
+
const actFromFrame = computer.actFromFrame;
|
|
190
|
+
const refreshComputer = computer.refresh;
|
|
191
|
+
const frames = useComputerFrameStream({
|
|
192
|
+
...override,
|
|
193
|
+
computerSessionId: selection?.sessionId ?? null,
|
|
194
|
+
targetId: computer.selectedTarget?.id ?? null,
|
|
195
|
+
enabled: enabled && selection !== null && controllerReady && computer.selectedTarget !== null,
|
|
196
|
+
stream: {
|
|
197
|
+
format: "jpeg",
|
|
198
|
+
quality: 78,
|
|
199
|
+
maxWidth: 1_920,
|
|
200
|
+
maxHeight: 1_200,
|
|
201
|
+
everyNthFrame: 1,
|
|
202
|
+
},
|
|
203
|
+
...(webSocketFactory ? { webSocketFactory } : {}),
|
|
204
|
+
});
|
|
205
|
+
const displayedFrame =
|
|
206
|
+
frames.frame &&
|
|
207
|
+
frames.frame.computerSessionId === selection?.sessionId &&
|
|
208
|
+
frames.frame.targetId === computer.selectedTarget?.id
|
|
209
|
+
? frames.frame
|
|
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]);
|
|
232
|
+
const machineLocked = selectedRegistrySession?.failureCode === "machine_locked";
|
|
233
|
+
const currentIds = useMemo(() => new Set(relevant.map((session) => session.id)), [relevant]);
|
|
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
|
+
|
|
265
|
+
const createComputer = useCallback(() => {
|
|
266
|
+
if (creating) return;
|
|
267
|
+
setCreating(true);
|
|
268
|
+
void createSession({ sessionId, name: "Computer" })
|
|
269
|
+
.then((response) => {
|
|
270
|
+
setSelection({ sessionId: response.session.id, pinned: false });
|
|
271
|
+
})
|
|
272
|
+
.catch((cause) => notifyError(cause, "Could not open a computer."))
|
|
273
|
+
.finally(() => setCreating(false));
|
|
274
|
+
}, [createSession, creating, notifyError, sessionId]);
|
|
275
|
+
|
|
276
|
+
const perform = useCallback(
|
|
277
|
+
async (action: ComputerAction, frame: ComputerFrame | null): Promise<void> => {
|
|
278
|
+
if (action.type === "pointer") {
|
|
279
|
+
if (!frame) throw new Error("Computer view is not ready for pointer input.");
|
|
280
|
+
await actFromFrame(action, frame);
|
|
281
|
+
} else {
|
|
282
|
+
await act(action);
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
[act, actFromFrame],
|
|
286
|
+
);
|
|
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
|
+
|
|
325
|
+
if (!enabled) return null;
|
|
326
|
+
if (registry.loading && liveSessions.length === 0) {
|
|
327
|
+
return (
|
|
328
|
+
<ComputerNotice
|
|
329
|
+
icon={<LoaderCircleIcon className="size-4 animate-spin" />}
|
|
330
|
+
text="Finding workspace computers…"
|
|
331
|
+
{...(className ? { className } : {})}
|
|
332
|
+
/>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
if (liveSessions.length === 0) {
|
|
336
|
+
if (renderEmpty) return renderEmpty(createComputer, creating);
|
|
337
|
+
return (
|
|
338
|
+
<div className={cn("grid h-full place-items-center bg-og-bg p-6", className)}>
|
|
339
|
+
<div className="max-w-sm text-center">
|
|
340
|
+
<span className="mx-auto grid size-10 place-items-center rounded-og-lg border border-og-border bg-og-surface-1 text-og-muted">
|
|
341
|
+
<MonitorIcon className="size-5" />
|
|
342
|
+
</span>
|
|
343
|
+
<p className="mt-3 text-og-menu font-medium text-og-fg">No computer open</p>
|
|
344
|
+
<p className="mt-1 text-og-control leading-5 text-og-muted">
|
|
345
|
+
Open this agent's desktop, or switch to any computer another workspace agent uses.
|
|
346
|
+
</p>
|
|
347
|
+
<button
|
|
348
|
+
type="button"
|
|
349
|
+
onClick={createComputer}
|
|
350
|
+
disabled={creating}
|
|
351
|
+
className="mt-4 inline-flex h-8 items-center gap-1.5 rounded-og-sm bg-og-accent-deep px-3 text-og-control font-medium text-og-accent-fg transition hover:brightness-110 disabled:opacity-50"
|
|
352
|
+
>
|
|
353
|
+
{creating ? (
|
|
354
|
+
<LoaderCircleIcon className="size-3.5 animate-spin" />
|
|
355
|
+
) : (
|
|
356
|
+
<PlusIcon className="size-3.5" />
|
|
357
|
+
)}
|
|
358
|
+
Open computer
|
|
359
|
+
</button>
|
|
360
|
+
{registry.error ? (
|
|
361
|
+
<p className="mt-3 text-og-control text-og-status-error">{registry.error.message}</p>
|
|
362
|
+
) : null}
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return (
|
|
369
|
+
<div className={cn("flex h-full min-h-0 flex-col overflow-hidden bg-og-bg", className)}>
|
|
370
|
+
<ComputerToolbar
|
|
371
|
+
sessions={liveSessions}
|
|
372
|
+
relevantSessionIds={currentIds}
|
|
373
|
+
selectedSessionId={selection?.sessionId ?? null}
|
|
374
|
+
creating={creating}
|
|
375
|
+
refreshing={registry.refreshing}
|
|
376
|
+
interventionCounts={interventionCounts}
|
|
377
|
+
onSelect={(computerSessionId) =>
|
|
378
|
+
setSelection({ sessionId: computerSessionId, pinned: true })
|
|
379
|
+
}
|
|
380
|
+
onFollow={() => {
|
|
381
|
+
if (relevant[0]) setSelection({ sessionId: relevant[0].id, pinned: false });
|
|
382
|
+
}}
|
|
383
|
+
onCreate={createComputer}
|
|
384
|
+
onRefresh={() => void Promise.all([refreshRegistry(), refreshComputer()])}
|
|
385
|
+
/>
|
|
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
|
+
/>
|
|
410
|
+
) : (
|
|
411
|
+
<>
|
|
412
|
+
<ComputerTargetRail
|
|
413
|
+
targets={computer.targets}
|
|
414
|
+
selectedTargetId={computer.selectedTarget?.id ?? null}
|
|
415
|
+
loading={computer.loading}
|
|
416
|
+
onSelect={(targetId) =>
|
|
417
|
+
void computer
|
|
418
|
+
.selectTarget(targetId)
|
|
419
|
+
.catch((cause) => notifyError(cause, "Could not switch computer views."))
|
|
420
|
+
}
|
|
421
|
+
/>
|
|
422
|
+
<div className="flex min-h-0 flex-1">
|
|
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
|
+
)}
|
|
452
|
+
{showControls ? (
|
|
453
|
+
<ComputerSemanticPanel
|
|
454
|
+
observation={computer.observation}
|
|
455
|
+
mutating={computer.mutating}
|
|
456
|
+
onAction={(action) =>
|
|
457
|
+
void perform(action, null).catch((cause) =>
|
|
458
|
+
notifyError(cause, "Computer action failed."),
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
/>
|
|
462
|
+
) : null}
|
|
463
|
+
</div>
|
|
464
|
+
<ComputerStatusBar
|
|
465
|
+
session={computer.session}
|
|
466
|
+
target={computer.selectedTarget}
|
|
467
|
+
connectionState={frames.state}
|
|
468
|
+
refreshing={registry.refreshing}
|
|
469
|
+
showControls={showControls}
|
|
470
|
+
controlCount={semanticNodes(computer.observation).length}
|
|
471
|
+
onToggleControls={() => setShowControls((current) => !current)}
|
|
472
|
+
/>
|
|
473
|
+
</>
|
|
474
|
+
)}
|
|
475
|
+
</div>
|
|
476
|
+
);
|
|
477
|
+
}
|
|
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
|
+
|
|
514
|
+
function ComputerToolbar(props: {
|
|
515
|
+
sessions: ComputerSession[];
|
|
516
|
+
relevantSessionIds: Set<string>;
|
|
517
|
+
selectedSessionId: string | null;
|
|
518
|
+
creating: boolean;
|
|
519
|
+
refreshing: boolean;
|
|
520
|
+
interventionCounts: Map<string, number>;
|
|
521
|
+
onSelect: (id: string) => void;
|
|
522
|
+
onFollow: () => void;
|
|
523
|
+
onCreate: () => void;
|
|
524
|
+
onRefresh: () => void;
|
|
525
|
+
}) {
|
|
526
|
+
const detailsRef = useRef<HTMLDetailsElement | null>(null);
|
|
527
|
+
const selected = props.sessions.find((session) => session.id === props.selectedSessionId);
|
|
528
|
+
const current = props.sessions.filter((session) => props.relevantSessionIds.has(session.id));
|
|
529
|
+
const others = props.sessions.filter((session) => !props.relevantSessionIds.has(session.id));
|
|
530
|
+
const choose = (id: string) => {
|
|
531
|
+
props.onSelect(id);
|
|
532
|
+
detailsRef.current?.removeAttribute("open");
|
|
533
|
+
};
|
|
534
|
+
return (
|
|
535
|
+
<div className="flex h-10 shrink-0 items-center gap-1.5 border-b border-og-border bg-og-surface-0 px-2">
|
|
536
|
+
<details ref={detailsRef} className="relative min-w-0">
|
|
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">
|
|
538
|
+
<MonitorIcon className="size-3.5 shrink-0 text-og-muted" />
|
|
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}
|
|
543
|
+
<ChevronDownIcon className="size-3 shrink-0 text-og-subtle" />
|
|
544
|
+
</summary>
|
|
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">
|
|
546
|
+
<ComputerSessionGroup
|
|
547
|
+
label="Current agent"
|
|
548
|
+
sessions={current}
|
|
549
|
+
selectedId={props.selectedSessionId}
|
|
550
|
+
interventionCounts={props.interventionCounts}
|
|
551
|
+
onSelect={choose}
|
|
552
|
+
/>
|
|
553
|
+
<ComputerSessionGroup
|
|
554
|
+
label="Other agents"
|
|
555
|
+
sessions={others}
|
|
556
|
+
selectedId={props.selectedSessionId}
|
|
557
|
+
interventionCounts={props.interventionCounts}
|
|
558
|
+
onSelect={choose}
|
|
559
|
+
/>
|
|
560
|
+
<div className="mt-1 flex gap-1 border-t border-og-border pt-1">
|
|
561
|
+
{current.length > 0 ? (
|
|
562
|
+
<MenuButton onClick={props.onFollow}>Follow agent</MenuButton>
|
|
563
|
+
) : null}
|
|
564
|
+
<MenuButton onClick={props.onCreate} disabled={props.creating}>
|
|
565
|
+
<PlusIcon className="size-3.5" /> New computer
|
|
566
|
+
</MenuButton>
|
|
567
|
+
</div>
|
|
568
|
+
</div>
|
|
569
|
+
</details>
|
|
570
|
+
<span className="min-w-0 flex-1 truncate text-og-xs text-og-subtle">
|
|
571
|
+
{selected ? `${platformLabel(selected)} · ${placementLabel(selected)}` : ""}
|
|
572
|
+
</span>
|
|
573
|
+
<button
|
|
574
|
+
type="button"
|
|
575
|
+
onClick={props.onRefresh}
|
|
576
|
+
className="grid size-7 place-items-center rounded-og-sm text-og-muted transition hover:bg-og-surface-2 hover:text-og-fg"
|
|
577
|
+
aria-label="Refresh computers"
|
|
578
|
+
>
|
|
579
|
+
<RefreshCwIcon className={cn("size-3.5", props.refreshing && "animate-spin")} />
|
|
580
|
+
</button>
|
|
581
|
+
<button
|
|
582
|
+
type="button"
|
|
583
|
+
onClick={props.onCreate}
|
|
584
|
+
disabled={props.creating}
|
|
585
|
+
className="grid size-7 place-items-center rounded-og-sm text-og-muted transition hover:bg-og-surface-2 hover:text-og-fg disabled:opacity-40"
|
|
586
|
+
aria-label="Open a new computer"
|
|
587
|
+
>
|
|
588
|
+
{props.creating ? (
|
|
589
|
+
<LoaderCircleIcon className="size-3.5 animate-spin" />
|
|
590
|
+
) : (
|
|
591
|
+
<PlusIcon className="size-3.5" />
|
|
592
|
+
)}
|
|
593
|
+
</button>
|
|
594
|
+
</div>
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function ComputerSessionGroup(props: {
|
|
599
|
+
label: string;
|
|
600
|
+
sessions: ComputerSession[];
|
|
601
|
+
selectedId: string | null;
|
|
602
|
+
interventionCounts: Map<string, number>;
|
|
603
|
+
onSelect: (id: string) => void;
|
|
604
|
+
}) {
|
|
605
|
+
if (props.sessions.length === 0) return null;
|
|
606
|
+
return (
|
|
607
|
+
<div className="py-1">
|
|
608
|
+
<p className="px-2 pb-1 text-[10px] font-medium uppercase tracking-[0.12em] text-og-subtle">
|
|
609
|
+
{props.label}
|
|
610
|
+
</p>
|
|
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)}
|
|
618
|
+
className={cn(
|
|
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",
|
|
621
|
+
)}
|
|
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>
|
|
634
|
+
</span>
|
|
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
|
+
})}
|
|
643
|
+
</div>
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function MenuButton(props: { children: ReactNode; onClick: () => void; disabled?: boolean }) {
|
|
648
|
+
return (
|
|
649
|
+
<button
|
|
650
|
+
type="button"
|
|
651
|
+
onClick={props.onClick}
|
|
652
|
+
disabled={props.disabled}
|
|
653
|
+
className="flex h-7 flex-1 items-center justify-center gap-1 rounded-og-sm px-2 text-og-control text-og-muted transition hover:bg-og-surface-2 hover:text-og-fg disabled:opacity-50"
|
|
654
|
+
>
|
|
655
|
+
{props.children}
|
|
656
|
+
</button>
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function ComputerTargetRail(props: {
|
|
661
|
+
targets: ComputerTarget[];
|
|
662
|
+
selectedTargetId: string | null;
|
|
663
|
+
loading: boolean;
|
|
664
|
+
onSelect: (id: string) => void;
|
|
665
|
+
}) {
|
|
666
|
+
const visualTargets = props.targets.filter(isRenderableComputerView);
|
|
667
|
+
return (
|
|
668
|
+
<div
|
|
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"
|
|
670
|
+
aria-label="Computer views"
|
|
671
|
+
>
|
|
672
|
+
{visualTargets.map((target) => (
|
|
673
|
+
<button
|
|
674
|
+
key={target.id}
|
|
675
|
+
type="button"
|
|
676
|
+
onClick={() => props.onSelect(target.id)}
|
|
677
|
+
className={cn(
|
|
678
|
+
"flex h-7 max-w-56 shrink-0 items-center gap-1.5 rounded-og-sm border px-2 text-og-control transition",
|
|
679
|
+
target.id === props.selectedTargetId
|
|
680
|
+
? "border-og-border-strong bg-og-surface-2 text-og-fg"
|
|
681
|
+
: "border-transparent text-og-muted hover:bg-og-surface-1 hover:text-og-fg",
|
|
682
|
+
)}
|
|
683
|
+
aria-pressed={target.id === props.selectedTargetId}
|
|
684
|
+
>
|
|
685
|
+
{target.kind === "screen" ? (
|
|
686
|
+
<MonitorIcon className="size-3.5 shrink-0" />
|
|
687
|
+
) : (
|
|
688
|
+
<PanelsTopLeftIcon className="size-3.5 shrink-0" />
|
|
689
|
+
)}
|
|
690
|
+
<span className="truncate">{target.title || target.applicationId || target.kind}</span>
|
|
691
|
+
{target.focused ? (
|
|
692
|
+
<span
|
|
693
|
+
className="size-1.5 shrink-0 rounded-full bg-og-status-running"
|
|
694
|
+
aria-label="Focused"
|
|
695
|
+
/>
|
|
696
|
+
) : null}
|
|
697
|
+
</button>
|
|
698
|
+
))}
|
|
699
|
+
{props.loading ? (
|
|
700
|
+
<LoaderCircleIcon className="ml-1 size-3.5 animate-spin text-og-muted" />
|
|
701
|
+
) : null}
|
|
702
|
+
{!props.loading && visualTargets.length === 0 ? (
|
|
703
|
+
<span className="text-og-xs text-og-subtle">Waiting for apps and screens…</span>
|
|
704
|
+
) : null}
|
|
705
|
+
</div>
|
|
706
|
+
);
|
|
707
|
+
}
|
|
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
|
+
|
|
718
|
+
function ComputerLifecyclePanel(props: {
|
|
719
|
+
session: ComputerSession;
|
|
720
|
+
creating: boolean;
|
|
721
|
+
onRefresh: () => Promise<void>;
|
|
722
|
+
onRetry: () => void;
|
|
723
|
+
}) {
|
|
724
|
+
const failed = ["failed", "lost", "repair_required"].includes(props.session.lifecycle);
|
|
725
|
+
const retryCreatesSession = ["failed", "lost"].includes(props.session.lifecycle);
|
|
726
|
+
return (
|
|
727
|
+
<div className="grid min-h-0 flex-1 place-items-center bg-og-bg p-6">
|
|
728
|
+
<div className="max-w-sm text-center">
|
|
729
|
+
{failed ? (
|
|
730
|
+
<CircleAlertIcon className="mx-auto size-5 text-og-status-error" />
|
|
731
|
+
) : (
|
|
732
|
+
<LoaderCircleIcon className="mx-auto size-5 animate-spin text-og-muted" />
|
|
733
|
+
)}
|
|
734
|
+
<p className="mt-3 text-og-menu font-medium text-og-fg">
|
|
735
|
+
{failed ? "Computer needs attention" : lifecycleLabel(props.session.lifecycle)}
|
|
736
|
+
</p>
|
|
737
|
+
<p className="mt-1 text-og-control leading-5 text-og-muted">
|
|
738
|
+
{computerFailureMessage(props.session) ??
|
|
739
|
+
"The computer is being prepared on its placement. It will appear here when ready."}
|
|
740
|
+
</p>
|
|
741
|
+
{failed ? (
|
|
742
|
+
<button
|
|
743
|
+
type="button"
|
|
744
|
+
onClick={() => (retryCreatesSession ? props.onRetry() : void props.onRefresh())}
|
|
745
|
+
disabled={props.creating}
|
|
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"
|
|
747
|
+
>
|
|
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"}
|
|
754
|
+
</button>
|
|
755
|
+
) : null}
|
|
756
|
+
</div>
|
|
757
|
+
</div>
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function ComputerViewport(props: {
|
|
762
|
+
frame: ComputerFrame | null;
|
|
763
|
+
observation: ComputerObservation | null;
|
|
764
|
+
target: ComputerTarget | null;
|
|
765
|
+
machineLocked: boolean;
|
|
766
|
+
connectionState: string;
|
|
767
|
+
connectionError: Error | null;
|
|
768
|
+
mutating: boolean;
|
|
769
|
+
backgroundActions: boolean;
|
|
770
|
+
clipboardEnabled: boolean;
|
|
771
|
+
onAction: (action: ComputerAction, frame: ComputerFrame | null) => Promise<void>;
|
|
772
|
+
onReadClipboard: () => Promise<ComputerClipboard>;
|
|
773
|
+
onReconnect: () => void;
|
|
774
|
+
onError: (cause: unknown) => void;
|
|
775
|
+
}) {
|
|
776
|
+
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
777
|
+
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
|
778
|
+
const pointerStartRef = useRef<PointerStart | null>(null);
|
|
779
|
+
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
780
|
+
const lastClickRef = useRef<{
|
|
781
|
+
at: number;
|
|
782
|
+
x: number;
|
|
783
|
+
y: number;
|
|
784
|
+
frame: ComputerFrame;
|
|
785
|
+
} | null>(null);
|
|
786
|
+
const wheelRef = useRef<{
|
|
787
|
+
x: number;
|
|
788
|
+
y: number;
|
|
789
|
+
deltaX: number;
|
|
790
|
+
deltaY: number;
|
|
791
|
+
frame: ComputerFrame;
|
|
792
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
793
|
+
} | null>(null);
|
|
794
|
+
const pendingTextRef = useRef<{
|
|
795
|
+
text: string;
|
|
796
|
+
timer: ReturnType<typeof setTimeout>;
|
|
797
|
+
} | null>(null);
|
|
798
|
+
const actionRef = useRef(props.onAction);
|
|
799
|
+
const readClipboardRef = useRef(props.onReadClipboard);
|
|
800
|
+
const errorRef = useRef(props.onError);
|
|
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);
|
|
805
|
+
actionRef.current = props.onAction;
|
|
806
|
+
readClipboardRef.current = props.onReadClipboard;
|
|
807
|
+
errorRef.current = props.onError;
|
|
808
|
+
const rawInputEnabled =
|
|
809
|
+
!props.backgroundActions || props.target?.kind === "screen" || props.target?.focused === true;
|
|
810
|
+
|
|
811
|
+
const paintQueuedFrames = useCallback(() => {
|
|
812
|
+
if (decodingFrameRef.current) return;
|
|
813
|
+
decodingFrameRef.current = true;
|
|
814
|
+
void (async () => {
|
|
815
|
+
try {
|
|
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);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
} finally {
|
|
848
|
+
decodingFrameRef.current = false;
|
|
849
|
+
if (mountedRef.current && queuedFrameRef.current) paintQueuedFrames();
|
|
850
|
+
}
|
|
851
|
+
})();
|
|
852
|
+
}, []);
|
|
853
|
+
|
|
854
|
+
useEffect(() => {
|
|
855
|
+
mountedRef.current = true;
|
|
856
|
+
return () => {
|
|
857
|
+
mountedRef.current = false;
|
|
858
|
+
queuedFrameRef.current = null;
|
|
859
|
+
};
|
|
860
|
+
}, []);
|
|
861
|
+
|
|
862
|
+
useEffect(() => {
|
|
863
|
+
if (!props.frame) return;
|
|
864
|
+
queuedFrameRef.current = props.frame;
|
|
865
|
+
paintQueuedFrames();
|
|
866
|
+
}, [paintQueuedFrames, props.frame]);
|
|
867
|
+
|
|
868
|
+
useEffect(
|
|
869
|
+
() => () => {
|
|
870
|
+
if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
|
|
871
|
+
if (wheelRef.current?.timer) clearTimeout(wheelRef.current.timer);
|
|
872
|
+
if (pendingTextRef.current?.timer) clearTimeout(pendingTextRef.current.timer);
|
|
873
|
+
},
|
|
874
|
+
[],
|
|
875
|
+
);
|
|
876
|
+
|
|
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
|
+
);
|
|
889
|
+
|
|
890
|
+
const point = useCallback(
|
|
891
|
+
(frame: ComputerFrame, clientX: number, clientY: number) =>
|
|
892
|
+
computerPoint(canvasRef.current, frame, clientX, clientY),
|
|
893
|
+
[],
|
|
894
|
+
);
|
|
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
|
+
|
|
922
|
+
const pointerDown = (event: PointerEvent<HTMLCanvasElement>) => {
|
|
923
|
+
if (!props.frame || props.mutating || !rawInputEnabled || event.button !== 0) return;
|
|
924
|
+
flushPendingText();
|
|
925
|
+
pointerStartRef.current = {
|
|
926
|
+
x: event.clientX,
|
|
927
|
+
y: event.clientY,
|
|
928
|
+
pointerId: event.pointerId,
|
|
929
|
+
frame: props.frame,
|
|
930
|
+
};
|
|
931
|
+
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
932
|
+
inputRef.current?.focus({ preventScroll: true });
|
|
933
|
+
};
|
|
934
|
+
|
|
935
|
+
const pointerUp = (event: PointerEvent<HTMLCanvasElement>) => {
|
|
936
|
+
if (!rawInputEnabled) return;
|
|
937
|
+
const start = pointerStartRef.current;
|
|
938
|
+
pointerStartRef.current = null;
|
|
939
|
+
if (!start || start.pointerId !== event.pointerId) return;
|
|
940
|
+
const from = point(start.frame, start.x, start.y);
|
|
941
|
+
const to = point(start.frame, event.clientX, event.clientY);
|
|
942
|
+
if (!from || !to) return;
|
|
943
|
+
if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > 5) {
|
|
944
|
+
flushPendingClick();
|
|
945
|
+
enqueue(
|
|
946
|
+
{
|
|
947
|
+
type: "pointer",
|
|
948
|
+
frameId: start.frame.frameId,
|
|
949
|
+
action: "drag",
|
|
950
|
+
x: from.x,
|
|
951
|
+
y: from.y,
|
|
952
|
+
endX: to.x,
|
|
953
|
+
endY: to.y,
|
|
954
|
+
},
|
|
955
|
+
start.frame,
|
|
956
|
+
);
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
const now = Date.now();
|
|
960
|
+
const previous = lastClickRef.current;
|
|
961
|
+
if (
|
|
962
|
+
previous &&
|
|
963
|
+
now - previous.at < 280 &&
|
|
964
|
+
Math.hypot(previous.x - to.x, previous.y - to.y) < 6 &&
|
|
965
|
+
sameFrameFence(previous.frame, start.frame)
|
|
966
|
+
) {
|
|
967
|
+
if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
|
|
968
|
+
clickTimerRef.current = null;
|
|
969
|
+
lastClickRef.current = null;
|
|
970
|
+
enqueue(
|
|
971
|
+
{
|
|
972
|
+
type: "pointer",
|
|
973
|
+
frameId: start.frame.frameId,
|
|
974
|
+
action: "double_click",
|
|
975
|
+
x: to.x,
|
|
976
|
+
y: to.y,
|
|
977
|
+
},
|
|
978
|
+
start.frame,
|
|
979
|
+
);
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
lastClickRef.current = { at: now, x: to.x, y: to.y, frame: start.frame };
|
|
983
|
+
clickTimerRef.current = setTimeout(() => {
|
|
984
|
+
clickTimerRef.current = null;
|
|
985
|
+
lastClickRef.current = null;
|
|
986
|
+
enqueue(
|
|
987
|
+
{
|
|
988
|
+
type: "pointer",
|
|
989
|
+
frameId: start.frame.frameId,
|
|
990
|
+
action: "click",
|
|
991
|
+
x: to.x,
|
|
992
|
+
y: to.y,
|
|
993
|
+
},
|
|
994
|
+
start.frame,
|
|
995
|
+
);
|
|
996
|
+
}, 280);
|
|
997
|
+
};
|
|
998
|
+
|
|
999
|
+
const contextMenu = (event: MouseEvent<HTMLCanvasElement>) => {
|
|
1000
|
+
event.preventDefault();
|
|
1001
|
+
if (!rawInputEnabled) return;
|
|
1002
|
+
const frame = props.frame;
|
|
1003
|
+
if (!frame) return;
|
|
1004
|
+
flushPendingText();
|
|
1005
|
+
flushPendingClick();
|
|
1006
|
+
const at = point(frame, event.clientX, event.clientY);
|
|
1007
|
+
if (at) {
|
|
1008
|
+
enqueue(
|
|
1009
|
+
{
|
|
1010
|
+
type: "pointer",
|
|
1011
|
+
frameId: frame.frameId,
|
|
1012
|
+
action: "click",
|
|
1013
|
+
x: at.x,
|
|
1014
|
+
y: at.y,
|
|
1015
|
+
button: "right",
|
|
1016
|
+
},
|
|
1017
|
+
frame,
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
};
|
|
1021
|
+
|
|
1022
|
+
const wheel = (event: WheelEvent<HTMLCanvasElement>) => {
|
|
1023
|
+
if (!rawInputEnabled) return;
|
|
1024
|
+
const frame = props.frame;
|
|
1025
|
+
if (!frame) return;
|
|
1026
|
+
const at = point(frame, event.clientX, event.clientY);
|
|
1027
|
+
if (!at) return;
|
|
1028
|
+
flushPendingText();
|
|
1029
|
+
flushPendingClick();
|
|
1030
|
+
event.preventDefault();
|
|
1031
|
+
const pending = wheelRef.current;
|
|
1032
|
+
if (pending?.timer) clearTimeout(pending.timer);
|
|
1033
|
+
wheelRef.current = {
|
|
1034
|
+
x: at.x,
|
|
1035
|
+
y: at.y,
|
|
1036
|
+
deltaX: (pending && sameFrameFence(pending.frame, frame) ? pending.deltaX : 0) + event.deltaX,
|
|
1037
|
+
deltaY: (pending && sameFrameFence(pending.frame, frame) ? pending.deltaY : 0) + event.deltaY,
|
|
1038
|
+
frame,
|
|
1039
|
+
timer: setTimeout(() => {
|
|
1040
|
+
const batch = wheelRef.current;
|
|
1041
|
+
wheelRef.current = null;
|
|
1042
|
+
if (batch) {
|
|
1043
|
+
enqueue(
|
|
1044
|
+
{
|
|
1045
|
+
type: "pointer",
|
|
1046
|
+
frameId: batch.frame.frameId,
|
|
1047
|
+
action: "scroll",
|
|
1048
|
+
x: batch.x,
|
|
1049
|
+
y: batch.y,
|
|
1050
|
+
deltaX: batch.deltaX,
|
|
1051
|
+
deltaY: batch.deltaY,
|
|
1052
|
+
},
|
|
1053
|
+
batch.frame,
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
}, 45),
|
|
1057
|
+
};
|
|
1058
|
+
};
|
|
1059
|
+
|
|
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
|
+
}
|
|
1072
|
+
const key = computerKey(event);
|
|
1073
|
+
if (!key) return;
|
|
1074
|
+
event.preventDefault();
|
|
1075
|
+
flushPendingText();
|
|
1076
|
+
flushPendingClick();
|
|
1077
|
+
enqueue({ type: "keyboard", action: "press", value: key }, null);
|
|
1078
|
+
};
|
|
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
|
+
|
|
1106
|
+
const input = (value: string) => {
|
|
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
|
+
}
|
|
1120
|
+
if (inputRef.current) inputRef.current.value = "";
|
|
1121
|
+
};
|
|
1122
|
+
|
|
1123
|
+
const showCanvas = props.frame !== null && !props.machineLocked;
|
|
1124
|
+
return (
|
|
1125
|
+
<div className="relative min-h-0 flex-1 overflow-hidden bg-black">
|
|
1126
|
+
<canvas
|
|
1127
|
+
ref={canvasRef}
|
|
1128
|
+
className={cn(
|
|
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",
|
|
1130
|
+
!showCanvas && "invisible",
|
|
1131
|
+
!rawInputEnabled && "cursor-default",
|
|
1132
|
+
)}
|
|
1133
|
+
onPointerDown={pointerDown}
|
|
1134
|
+
onPointerUp={pointerUp}
|
|
1135
|
+
onPointerCancel={() => {
|
|
1136
|
+
pointerStartRef.current = null;
|
|
1137
|
+
}}
|
|
1138
|
+
onContextMenu={contextMenu}
|
|
1139
|
+
onWheel={wheel}
|
|
1140
|
+
aria-label={`Interactive ${props.target?.kind ?? "computer"} view`}
|
|
1141
|
+
/>
|
|
1142
|
+
<textarea
|
|
1143
|
+
ref={inputRef}
|
|
1144
|
+
defaultValue=""
|
|
1145
|
+
onInput={(event) => input(event.currentTarget.value)}
|
|
1146
|
+
onKeyDown={keyDown}
|
|
1147
|
+
onCopy={copy}
|
|
1148
|
+
onPaste={paste}
|
|
1149
|
+
className="pointer-events-none absolute left-1/2 top-1/2 size-px resize-none overflow-hidden opacity-0"
|
|
1150
|
+
aria-label="Computer keyboard input"
|
|
1151
|
+
autoCapitalize="off"
|
|
1152
|
+
autoCorrect="off"
|
|
1153
|
+
spellCheck={false}
|
|
1154
|
+
disabled={!rawInputEnabled}
|
|
1155
|
+
/>
|
|
1156
|
+
{!showCanvas ? (
|
|
1157
|
+
<ComputerViewportFallback
|
|
1158
|
+
observation={props.observation}
|
|
1159
|
+
machineLocked={props.machineLocked}
|
|
1160
|
+
connectionState={props.connectionState}
|
|
1161
|
+
error={props.connectionError}
|
|
1162
|
+
onAction={(action) => enqueue(action, null)}
|
|
1163
|
+
onReconnect={props.onReconnect}
|
|
1164
|
+
/>
|
|
1165
|
+
) : null}
|
|
1166
|
+
{props.mutating ? (
|
|
1167
|
+
<div className="pointer-events-none absolute right-3 top-3 flex items-center gap-1.5 rounded-full border border-white/10 bg-black/65 px-2.5 py-1 text-[11px] text-white/80 backdrop-blur">
|
|
1168
|
+
<LoaderCircleIcon className="size-3 animate-spin" /> Acting
|
|
1169
|
+
</div>
|
|
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}
|
|
1186
|
+
</div>
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
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
|
+
|
|
1196
|
+
function ComputerViewportFallback(props: {
|
|
1197
|
+
observation: ComputerObservation | null;
|
|
1198
|
+
machineLocked: boolean;
|
|
1199
|
+
connectionState: string;
|
|
1200
|
+
error: Error | null;
|
|
1201
|
+
onAction: (action: ComputerAction) => void;
|
|
1202
|
+
onReconnect: () => void;
|
|
1203
|
+
}) {
|
|
1204
|
+
if (props.machineLocked) {
|
|
1205
|
+
return (
|
|
1206
|
+
<div className="absolute inset-0 grid place-items-center bg-og-bg p-6">
|
|
1207
|
+
<div className="max-w-sm text-center">
|
|
1208
|
+
<LockKeyholeIcon className="mx-auto size-6 text-og-muted" />
|
|
1209
|
+
<p className="mt-3 text-og-menu font-medium text-og-fg">Machine locked</p>
|
|
1210
|
+
<p className="mt-1 text-og-control leading-5 text-og-muted">
|
|
1211
|
+
Live frames and input stay private until someone unlocks the machine.
|
|
1212
|
+
</p>
|
|
1213
|
+
</div>
|
|
1214
|
+
</div>
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
const interactive = semanticNodes(props.observation)
|
|
1218
|
+
.filter((node) => semanticAction(node) !== null)
|
|
1219
|
+
.slice(0, 10);
|
|
1220
|
+
return (
|
|
1221
|
+
<div className="absolute inset-0 grid place-items-center bg-og-bg p-6">
|
|
1222
|
+
<div className="w-full max-w-md rounded-og-lg border border-og-border bg-og-surface-0 p-4 shadow-lg">
|
|
1223
|
+
<div className="flex items-center gap-2">
|
|
1224
|
+
{props.error ? (
|
|
1225
|
+
<CircleAlertIcon className="size-4 text-og-status-error" />
|
|
1226
|
+
) : (
|
|
1227
|
+
<LoaderCircleIcon className="size-4 animate-spin text-og-muted" />
|
|
1228
|
+
)}
|
|
1229
|
+
<p className="text-og-menu font-medium text-og-fg">
|
|
1230
|
+
{props.error
|
|
1231
|
+
? "Live view disconnected"
|
|
1232
|
+
: computerConnectionLabel(props.connectionState)}
|
|
1233
|
+
</p>
|
|
1234
|
+
</div>
|
|
1235
|
+
{interactive.length > 0 ? (
|
|
1236
|
+
<div className="mt-3 border-t border-og-border pt-3">
|
|
1237
|
+
<p className="mb-2 text-og-xs text-og-subtle">Native controls remain available</p>
|
|
1238
|
+
<div className="flex flex-wrap gap-1.5">
|
|
1239
|
+
{interactive.map((node) => (
|
|
1240
|
+
<button
|
|
1241
|
+
key={node.ref}
|
|
1242
|
+
type="button"
|
|
1243
|
+
onClick={() => {
|
|
1244
|
+
const action = semanticAction(node);
|
|
1245
|
+
if (action) props.onAction(action);
|
|
1246
|
+
}}
|
|
1247
|
+
className="rounded-og-sm border border-og-border bg-og-surface-1 px-2 py-1 text-og-control text-og-fg transition hover:bg-og-surface-2"
|
|
1248
|
+
>
|
|
1249
|
+
{node.name || node.role}
|
|
1250
|
+
</button>
|
|
1251
|
+
))}
|
|
1252
|
+
</div>
|
|
1253
|
+
</div>
|
|
1254
|
+
) : null}
|
|
1255
|
+
{props.error ? (
|
|
1256
|
+
<button
|
|
1257
|
+
type="button"
|
|
1258
|
+
onClick={props.onReconnect}
|
|
1259
|
+
className="mt-3 text-og-control font-medium text-og-accent hover:underline"
|
|
1260
|
+
>
|
|
1261
|
+
Reconnect
|
|
1262
|
+
</button>
|
|
1263
|
+
) : null}
|
|
1264
|
+
</div>
|
|
1265
|
+
</div>
|
|
1266
|
+
);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function ComputerSemanticPanel(props: {
|
|
1270
|
+
observation: ComputerObservation | null;
|
|
1271
|
+
mutating: boolean;
|
|
1272
|
+
onAction: (action: ComputerAction) => void;
|
|
1273
|
+
}) {
|
|
1274
|
+
const nodes = semanticNodes(props.observation).slice(0, 100);
|
|
1275
|
+
return (
|
|
1276
|
+
<aside className="w-64 shrink-0 overflow-y-auto border-l border-og-border bg-og-surface-0 p-2">
|
|
1277
|
+
<div className="mb-2 flex items-center gap-1.5 px-1 text-og-xs font-medium uppercase tracking-[0.1em] text-og-subtle">
|
|
1278
|
+
<KeyboardIcon className="size-3" /> Native controls
|
|
1279
|
+
</div>
|
|
1280
|
+
{nodes.length === 0 ? (
|
|
1281
|
+
<p className="px-1 py-2 text-og-control leading-5 text-og-muted">
|
|
1282
|
+
This view has no accessibility controls. Use the live image instead.
|
|
1283
|
+
</p>
|
|
1284
|
+
) : (
|
|
1285
|
+
<div className="space-y-0.5">
|
|
1286
|
+
{nodes.map((node) => (
|
|
1287
|
+
<ComputerSemanticControl
|
|
1288
|
+
key={node.ref}
|
|
1289
|
+
node={node}
|
|
1290
|
+
mutating={props.mutating}
|
|
1291
|
+
onAction={props.onAction}
|
|
1292
|
+
/>
|
|
1293
|
+
))}
|
|
1294
|
+
</div>
|
|
1295
|
+
)}
|
|
1296
|
+
</aside>
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
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
|
+
|
|
1369
|
+
function ComputerStatusBar(props: {
|
|
1370
|
+
session: ComputerSession | null;
|
|
1371
|
+
target: ComputerTarget | null;
|
|
1372
|
+
connectionState: string;
|
|
1373
|
+
refreshing: boolean;
|
|
1374
|
+
showControls: boolean;
|
|
1375
|
+
controlCount: number;
|
|
1376
|
+
onToggleControls: () => void;
|
|
1377
|
+
}) {
|
|
1378
|
+
const screen = props.target?.kind === "screen";
|
|
1379
|
+
return (
|
|
1380
|
+
<div className="flex h-8 shrink-0 items-center gap-2 border-t border-og-border bg-og-surface-0 px-2 text-og-xs text-og-subtle">
|
|
1381
|
+
<span
|
|
1382
|
+
className={cn(
|
|
1383
|
+
"size-1.5 rounded-full",
|
|
1384
|
+
props.connectionState === "live" ? "bg-og-status-running" : "bg-og-muted",
|
|
1385
|
+
)}
|
|
1386
|
+
/>
|
|
1387
|
+
<span>
|
|
1388
|
+
{props.connectionState === "live" ? "Live" : computerConnectionLabel(props.connectionState)}
|
|
1389
|
+
</span>
|
|
1390
|
+
<span className="min-w-0 flex-1 truncate">
|
|
1391
|
+
{screen
|
|
1392
|
+
? "Full screen · input may move pointer and focus"
|
|
1393
|
+
: props.session?.capabilities?.backgroundActions
|
|
1394
|
+
? "Window · semantic controls stay in the background"
|
|
1395
|
+
: (props.target?.kind ?? "Computer")}
|
|
1396
|
+
</span>
|
|
1397
|
+
{screen ? <MousePointer2Icon className="size-3" aria-hidden /> : null}
|
|
1398
|
+
{props.refreshing ? <LoaderCircleIcon className="size-3 animate-spin" /> : null}
|
|
1399
|
+
<button
|
|
1400
|
+
type="button"
|
|
1401
|
+
onClick={props.onToggleControls}
|
|
1402
|
+
aria-pressed={props.showControls}
|
|
1403
|
+
className={cn(
|
|
1404
|
+
"rounded px-1.5 py-0.5 transition hover:bg-og-surface-2 hover:text-og-fg",
|
|
1405
|
+
props.showControls && "bg-og-surface-2 text-og-fg",
|
|
1406
|
+
)}
|
|
1407
|
+
>
|
|
1408
|
+
Controls {props.controlCount}
|
|
1409
|
+
</button>
|
|
1410
|
+
</div>
|
|
1411
|
+
);
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function ComputerNotice(props: { icon: ReactNode; text: string; className?: string }) {
|
|
1415
|
+
return (
|
|
1416
|
+
<div
|
|
1417
|
+
className={cn(
|
|
1418
|
+
"grid h-full place-items-center bg-og-bg text-og-control text-og-muted",
|
|
1419
|
+
props.className,
|
|
1420
|
+
)}
|
|
1421
|
+
>
|
|
1422
|
+
<div className="flex items-center gap-2">
|
|
1423
|
+
{props.icon}
|
|
1424
|
+
{props.text}
|
|
1425
|
+
</div>
|
|
1426
|
+
</div>
|
|
1427
|
+
);
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function semanticNodes(observation: ComputerObservation | null): InteractionSemanticNode[] {
|
|
1431
|
+
if (observation?.semantic?.kind !== "snapshot") return [];
|
|
1432
|
+
const result: InteractionSemanticNode[] = [];
|
|
1433
|
+
const visit = (node: InteractionSemanticNode) => {
|
|
1434
|
+
result.push(node);
|
|
1435
|
+
node.children?.forEach(visit);
|
|
1436
|
+
};
|
|
1437
|
+
observation.semantic.roots.forEach(visit);
|
|
1438
|
+
return result;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function semanticAction(node: InteractionSemanticNode): ComputerAction | null {
|
|
1442
|
+
const invoke = ["invoke", "press", "click"].some((action) => node.actions.includes(action));
|
|
1443
|
+
if (invoke) {
|
|
1444
|
+
return {
|
|
1445
|
+
type: "semantic",
|
|
1446
|
+
locator: { kind: "ref", ref: node.ref },
|
|
1447
|
+
action: "invoke",
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
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.
|
|
1453
|
+
return null;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function semanticValue(node: InteractionSemanticNode): string | null {
|
|
1457
|
+
return typeof node.value === "string" ? node.value : null;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
function paintCanvas(
|
|
1461
|
+
canvas: HTMLCanvasElement,
|
|
1462
|
+
image: CanvasImageSource,
|
|
1463
|
+
width: number,
|
|
1464
|
+
height: number,
|
|
1465
|
+
): void {
|
|
1466
|
+
canvas.width = width;
|
|
1467
|
+
canvas.height = height;
|
|
1468
|
+
const context = canvas.getContext("2d", { alpha: false });
|
|
1469
|
+
if (!context) throw new Error("Computer canvas is unavailable.");
|
|
1470
|
+
context.drawImage(image, 0, 0, width, height);
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
function loadImage(url: string): Promise<HTMLImageElement> {
|
|
1474
|
+
return new Promise((resolve, reject) => {
|
|
1475
|
+
const image = new Image();
|
|
1476
|
+
image.onload = () => resolve(image);
|
|
1477
|
+
image.onerror = () => reject(new Error("Computer frame image could not be decoded."));
|
|
1478
|
+
image.src = url;
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
function computerPoint(
|
|
1483
|
+
canvas: HTMLCanvasElement | null,
|
|
1484
|
+
frame: ComputerFrame,
|
|
1485
|
+
clientX: number,
|
|
1486
|
+
clientY: number,
|
|
1487
|
+
): { x: number; y: number } | null {
|
|
1488
|
+
if (!canvas) return null;
|
|
1489
|
+
const rect = canvas.getBoundingClientRect();
|
|
1490
|
+
if (rect.width <= 0 || rect.height <= 0) return null;
|
|
1491
|
+
return {
|
|
1492
|
+
x: Math.max(0, Math.min(frame.width - 1, ((clientX - rect.left) / rect.width) * frame.width)),
|
|
1493
|
+
y: Math.max(0, Math.min(frame.height - 1, ((clientY - rect.top) / rect.height) * frame.height)),
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
|
|
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;
|
|
1504
|
+
const special = new Set([
|
|
1505
|
+
"Enter",
|
|
1506
|
+
"Tab",
|
|
1507
|
+
"Escape",
|
|
1508
|
+
"Backspace",
|
|
1509
|
+
"Delete",
|
|
1510
|
+
"ArrowUp",
|
|
1511
|
+
"ArrowDown",
|
|
1512
|
+
"ArrowLeft",
|
|
1513
|
+
"ArrowRight",
|
|
1514
|
+
"Home",
|
|
1515
|
+
"End",
|
|
1516
|
+
"PageUp",
|
|
1517
|
+
"PageDown",
|
|
1518
|
+
]);
|
|
1519
|
+
const modified = event.altKey || event.ctrlKey || event.metaKey;
|
|
1520
|
+
if (!modified && !special.has(event.key)) return null;
|
|
1521
|
+
const parts: string[] = [];
|
|
1522
|
+
if (event.ctrlKey) parts.push("Control");
|
|
1523
|
+
if (event.altKey) parts.push("Alt");
|
|
1524
|
+
if (event.metaKey) parts.push("Meta");
|
|
1525
|
+
if (event.shiftKey) parts.push("Shift");
|
|
1526
|
+
parts.push(event.key === " " ? "Space" : event.key);
|
|
1527
|
+
return parts.join("+");
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
function sameFrameFence(left: ComputerFrame, right: ComputerFrame): boolean {
|
|
1531
|
+
return (
|
|
1532
|
+
left.frameId === right.frameId &&
|
|
1533
|
+
left.computerSessionId === right.computerSessionId &&
|
|
1534
|
+
left.targetId === right.targetId &&
|
|
1535
|
+
left.targetGeneration === right.targetGeneration
|
|
1536
|
+
);
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
function isLiveComputer(session: ComputerSession): boolean {
|
|
1540
|
+
return !["ending", "ended", "failed", "lost"].includes(session.lifecycle);
|
|
1541
|
+
}
|
|
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
|
+
|
|
1580
|
+
function platformLabel(session: ComputerSession): string {
|
|
1581
|
+
switch (session.platform) {
|
|
1582
|
+
case "macos":
|
|
1583
|
+
return "Mac";
|
|
1584
|
+
case "linux":
|
|
1585
|
+
return "Linux";
|
|
1586
|
+
case "windows":
|
|
1587
|
+
return "Windows";
|
|
1588
|
+
default:
|
|
1589
|
+
return "Computer";
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
function placementLabel(session: ComputerSession): string {
|
|
1594
|
+
switch (session.placement.kind) {
|
|
1595
|
+
case "sandbox_group":
|
|
1596
|
+
return "Agent sandbox";
|
|
1597
|
+
case "connected_machine":
|
|
1598
|
+
return "Connected machine";
|
|
1599
|
+
case "attached_device":
|
|
1600
|
+
return "Your machine";
|
|
1601
|
+
case "external_provider":
|
|
1602
|
+
return "Cloud computer";
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
function lifecycleLabel(lifecycle: ComputerSession["lifecycle"]): string {
|
|
1607
|
+
switch (lifecycle) {
|
|
1608
|
+
case "starting":
|
|
1609
|
+
return "Opening computer…";
|
|
1610
|
+
case "suspending":
|
|
1611
|
+
return "Pausing computer…";
|
|
1612
|
+
case "suspended":
|
|
1613
|
+
return "Computer paused";
|
|
1614
|
+
case "restoring":
|
|
1615
|
+
return "Restoring computer…";
|
|
1616
|
+
case "ending":
|
|
1617
|
+
return "Closing computer…";
|
|
1618
|
+
default:
|
|
1619
|
+
return "Connecting to computer…";
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
function computerConnectionLabel(state: string): string {
|
|
1624
|
+
switch (state) {
|
|
1625
|
+
case "attaching":
|
|
1626
|
+
return "Opening computer view…";
|
|
1627
|
+
case "connecting":
|
|
1628
|
+
return "Connecting…";
|
|
1629
|
+
case "reconnecting":
|
|
1630
|
+
return "Reconnecting…";
|
|
1631
|
+
case "error":
|
|
1632
|
+
return "Disconnected";
|
|
1633
|
+
default:
|
|
1634
|
+
return "Waiting for computer…";
|
|
1635
|
+
}
|
|
1636
|
+
}
|