@opengeni/react 0.46.1 → 0.52.1
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 +158 -14
- 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 +170 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/chunk-6BBWWOOU.js +82 -0
- package/dist/chunk-6BBWWOOU.js.map +1 -0
- package/dist/{chunk-HFO4ERGQ.js → chunk-724BW4TW.js} +586 -35
- package/dist/chunk-724BW4TW.js.map +1 -0
- package/dist/chunk-7I2KB3OK.js +3895 -0
- package/dist/chunk-7I2KB3OK.js.map +1 -0
- package/dist/chunk-7WCLAEFV.js +2828 -0
- package/dist/chunk-7WCLAEFV.js.map +1 -0
- package/dist/{chunk-U3CSCMWA.js → chunk-FK6VG4LL.js} +9 -13
- package/dist/chunk-FK6VG4LL.js.map +1 -0
- package/dist/{chunk-SRFUT2ZU.js → chunk-FWS3KC3Z.js} +120 -64
- package/dist/chunk-FWS3KC3Z.js.map +1 -0
- package/dist/{chunk-EKZH6IDG.js → chunk-HDBG7XGO.js} +62 -1
- package/dist/chunk-HDBG7XGO.js.map +1 -0
- package/dist/chunk-HUIKIDM5.js +2148 -0
- package/dist/chunk-HUIKIDM5.js.map +1 -0
- package/dist/{chunk-LGVMUIRV.js → chunk-JAGDUCZQ.js} +1053 -390
- package/dist/chunk-JAGDUCZQ.js.map +1 -0
- package/dist/{chunk-LWR4MXSS.js → chunk-JR3QFY6B.js} +420 -35
- package/dist/chunk-JR3QFY6B.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-EB67J347.js → chunk-KZ73RL76.js} +111 -31
- package/dist/chunk-KZ73RL76.js.map +1 -0
- package/dist/chunk-PEI6RANI.js +3234 -0
- package/dist/chunk-PEI6RANI.js.map +1 -0
- package/dist/{chunk-3PB7MIT6.js → chunk-ROIRTM37.js} +220 -72
- package/dist/chunk-ROIRTM37.js.map +1 -0
- package/dist/chunk-VZTQ73XT.js +86 -0
- package/dist/chunk-VZTQ73XT.js.map +1 -0
- package/dist/{chunk-VCBFUSUZ.js → chunk-WSK7G5LE.js} +72 -4
- package/dist/chunk-WSK7G5LE.js.map +1 -0
- package/dist/{chunk-FGZUCXZF.js → chunk-WYA65Y3F.js} +16 -16
- package/dist/chunk-WYA65Y3F.js.map +1 -0
- package/dist/{chunk-6EDV6YFE.js → chunk-X4S56Y2H.js} +147 -35
- package/dist/chunk-X4S56Y2H.js.map +1 -0
- package/dist/client.d.ts +7 -1
- 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 +8 -0
- package/dist/components/artifacts/presentation-editor.d.ts +297 -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-viewer.d.ts +29 -0
- package/dist/components/chat-composer.d.ts +13 -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 +4 -2
- package/dist/components/composer.d.ts +29 -15
- package/dist/components/computer-viewer.d.ts +23 -0
- package/dist/components/copy-button.d.ts +2 -2
- package/dist/components/desktop-viewer.d.ts +25 -21
- 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/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 +5 -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 +5 -5
- package/dist/components/markdown.d.ts +1 -1
- package/dist/components/message-timeline.d.ts +22 -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 +5 -5
- package/dist/components/sandbox-terminal.d.ts +50 -4
- package/dist/components/sandbox-workspace.d.ts +30 -10
- 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 +3 -1
- package/dist/composer.d.ts +11 -11
- package/dist/composer.js +7 -7
- package/dist/hooks/internal.d.ts +7 -1
- package/dist/hooks/use-attached-browsers.d.ts +21 -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-frame-stream.d.ts +28 -0
- package/dist/hooks/use-browser-identities.d.ts +26 -0
- package/dist/hooks/use-browser-session.d.ts +28 -0
- package/dist/hooks/use-browser-sessions.d.ts +32 -0
- package/dist/hooks/use-codex-accounts.d.ts +3 -3
- 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 +28 -0
- package/dist/hooks/use-computer-sessions.d.ts +28 -0
- package/dist/hooks/use-desktop-stream.d.ts +1 -1
- package/dist/hooks/use-environments.d.ts +11 -5
- 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-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 +10 -3
- 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 +25 -4
- package/dist/hooks/use-sandbox-git.d.ts +13 -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-slash-commands.d.ts +1 -1
- package/dist/hooks/use-terminal-stream.d.ts +8 -2
- package/dist/hooks/use-transcription.d.ts +2 -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 +6 -4
- 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 +158 -154
- package/dist/index.js +1718 -908
- package/dist/index.js.map +1 -1
- package/dist/interaction.d.ts +23 -0
- package/dist/interaction.js +36 -0
- package/dist/interaction.js.map +1 -0
- package/dist/interactive-workbench-viewers-KHNOHWQE.js +13 -0
- package/dist/interactive-workbench-viewers-KHNOHWQE.js.map +1 -0
- package/dist/lib/composer-responsive-context.d.ts +9 -0
- package/dist/lib/relay-wire.d.ts +4 -0
- package/dist/lib/sandbox-liveness.d.ts +9 -0
- package/dist/lib/terminal-capability.d.ts +5 -0
- package/dist/lib/use-portal-token-style.d.ts +14 -3
- package/dist/lib/xterm-theme.d.ts +1 -1
- package/dist/machines.d.ts +30 -30
- package/dist/machines.js +3 -3
- package/dist/provider.d.ts +4 -4
- package/dist/{queue-surface-implementation-J4XJFKRW.js → queue-surface-implementation-2KVJTYXH.js} +117 -64
- 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 +15 -6
- package/dist/realtime.d.ts +4 -4
- package/dist/realtime.js +30 -33
- package/dist/realtime.js.map +1 -1
- package/dist/session-context.d.ts +19 -1
- package/dist/session-ui.d.ts +14 -14
- package/dist/session-ui.js +6 -5
- package/dist/session.d.ts +28 -28
- package/dist/session.js +5 -5
- package/dist/timeline/activity-rail.d.ts +6 -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 +6 -3
- package/dist/timeline/projection.d.ts +3 -3
- package/dist/timeline/registry.d.ts +10 -1
- 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 +42 -2
- 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/dist/voice-recording-store.d.ts +8 -2
- package/package.json +52 -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 +86 -0
- package/src/client.ts +90 -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 +99 -0
- package/src/components/artifacts/presentation-editor.tsx +3148 -0
- package/src/components/artifacts/spreadsheet-canvas.ts +799 -0
- package/src/components/artifacts/spreadsheet-grid.tsx +2129 -0
- package/src/components/browser-viewer.tsx +1592 -0
- package/src/components/chat-composer.tsx +25 -2
- package/src/components/code-editor.tsx +90 -31
- package/src/components/command-palette.tsx +1 -1
- package/src/components/composer-transcription-control.tsx +51 -22
- package/src/components/composer.tsx +49 -23
- package/src/components/computer-viewer.tsx +1139 -0
- package/src/components/copy-button.tsx +1 -1
- package/src/components/desktop-viewer.tsx +103 -75
- package/src/components/file-browser.tsx +39 -17
- package/src/components/generated-video-player.tsx +89 -0
- package/src/components/human-input-form.tsx +2 -2
- package/src/components/interactive-workbench-viewers.ts +2 -0
- package/src/components/machine-card.tsx +1 -1
- package/src/components/machine-input-display.ts +3 -0
- package/src/components/machines/machine-detail.tsx +39 -15
- package/src/components/machines-dashboard.tsx +7 -6
- package/src/components/message-timeline.tsx +327 -37
- package/src/components/model-picker.tsx +2 -2
- package/src/components/model-policy-picker.tsx +16 -15
- package/src/components/queue-surface-implementation.tsx +135 -75
- package/src/components/queue-surface-state.tsx +2 -2
- package/src/components/sandbox-files.tsx +142 -62
- package/src/components/sandbox-terminal.tsx +119 -35
- package/src/components/sandbox-workspace.tsx +394 -228
- package/src/components/session-chrome.tsx +67 -27
- 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/tooltip.tsx +13 -16
- package/src/components/user-message-body.tsx +1 -1
- package/src/components/workbench-changes.tsx +192 -35
- package/src/components/workspace-dock.tsx +13 -5
- package/src/composer.ts +3 -0
- package/src/hooks/internal.ts +132 -38
- package/src/hooks/use-attached-browsers.ts +152 -0
- package/src/hooks/use-browser-frame-stream.ts +316 -0
- package/src/hooks/use-browser-identities.ts +225 -0
- package/src/hooks/use-browser-session.ts +476 -0
- package/src/hooks/use-browser-sessions.ts +281 -0
- package/src/hooks/use-codex-accounts.ts +4 -4
- package/src/hooks/use-composer.ts +769 -24
- package/src/hooks/use-computer-frame-stream.ts +311 -0
- package/src/hooks/use-computer-session.ts +442 -0
- package/src/hooks/use-computer-sessions.ts +245 -0
- package/src/hooks/use-environments.ts +24 -5
- package/src/hooks/use-file-attachments.ts +70 -32
- package/src/hooks/use-goal.ts +17 -5
- package/src/hooks/use-machine-chip.ts +5 -3
- package/src/hooks/use-machines.ts +79 -3
- package/src/hooks/use-packs.ts +51 -0
- package/src/hooks/use-sandbox-files.ts +382 -88
- package/src/hooks/use-sandbox-git.ts +208 -46
- package/src/hooks/use-sandbox-terminal.ts +3 -2
- package/src/hooks/use-session-capabilities.ts +94 -70
- package/src/hooks/use-session-events.ts +18 -4
- package/src/hooks/use-terminal-stream.ts +102 -23
- package/src/hooks/use-transcription.ts +4 -14
- package/src/hooks/use-turn-queue.ts +17 -5
- package/src/hooks/use-video-artifact-playback.ts +28 -0
- package/src/hooks/use-voice-input.ts +163 -19
- package/src/hooks/use-windowed-sections.ts +9 -7
- package/src/hooks/use-workspace-edit.ts +2 -1
- package/src/index.ts +21 -6
- package/src/interaction.ts +71 -0
- package/src/lib/composer-responsive-context.ts +15 -0
- package/src/lib/relay-wire.ts +4 -0
- package/src/lib/sandbox-liveness.ts +11 -0
- package/src/lib/terminal-capability.ts +10 -0
- package/src/lib/use-portal-token-style.ts +60 -15
- package/src/provider.tsx +52 -7
- package/src/realtime/dropdown-menu.tsx +15 -19
- package/src/realtime/realtime-control.tsx +16 -5
- package/src/session-context.ts +90 -0
- package/src/timeline/activity-rail.tsx +31 -6
- package/src/timeline/index.ts +4 -1
- package/src/timeline/parsers.ts +46 -20
- package/src/timeline/projection.ts +233 -13
- package/src/timeline/registry.ts +19 -0
- package/src/timeline/screenshot-lightbox.tsx +30 -9
- package/src/timeline/shared.tsx +59 -11
- package/src/timeline/tool-renderers.tsx +421 -24
- package/src/timeline/turn-summary.tsx +2 -2
- package/src/timeline/types.ts +58 -2
- package/src/voice-recording-store.ts +15 -0
- package/styles/compiled.css +5298 -0
- package/styles/compiled.d.ts +2 -0
- package/styles/effective-tokens.css +27 -0
- package/styles/index.css +18 -0
- package/styles/responsive.css +208 -0
- package/styles/responsive.d.ts +2 -0
- package/styles/tokens.css +50 -17
- package/dist/chunk-3PB7MIT6.js.map +0 -1
- package/dist/chunk-6EDV6YFE.js.map +0 -1
- package/dist/chunk-EB67J347.js.map +0 -1
- package/dist/chunk-EKZH6IDG.js.map +0 -1
- package/dist/chunk-FGZUCXZF.js.map +0 -1
- package/dist/chunk-HFO4ERGQ.js.map +0 -1
- package/dist/chunk-JB5725BI.js +0 -48
- package/dist/chunk-JB5725BI.js.map +0 -1
- package/dist/chunk-LGVMUIRV.js.map +0 -1
- package/dist/chunk-LWR4MXSS.js.map +0 -1
- package/dist/chunk-SRFUT2ZU.js.map +0 -1
- package/dist/chunk-U3CSCMWA.js.map +0 -1
- package/dist/chunk-VCBFUSUZ.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-J4XJFKRW.js.map +0 -1
|
@@ -0,0 +1,1592 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AttachedBrowserDevice,
|
|
3
|
+
BrowserAction,
|
|
4
|
+
BrowserFrame,
|
|
5
|
+
BrowserIdentity,
|
|
6
|
+
BrowserObservation,
|
|
7
|
+
BrowserSession,
|
|
8
|
+
BrowserTarget,
|
|
9
|
+
ComputerSession,
|
|
10
|
+
InteractionSemanticNode,
|
|
11
|
+
} from "@opengeni/sdk/interaction";
|
|
12
|
+
import {
|
|
13
|
+
BugIcon,
|
|
14
|
+
ChevronDownIcon,
|
|
15
|
+
CircleAlertIcon,
|
|
16
|
+
Globe2Icon,
|
|
17
|
+
LoaderCircleIcon,
|
|
18
|
+
MonitorIcon,
|
|
19
|
+
PlusIcon,
|
|
20
|
+
RefreshCwIcon,
|
|
21
|
+
SaveIcon,
|
|
22
|
+
UserRoundIcon,
|
|
23
|
+
XIcon,
|
|
24
|
+
} from "lucide-react";
|
|
25
|
+
import {
|
|
26
|
+
type FormEvent,
|
|
27
|
+
type KeyboardEvent,
|
|
28
|
+
type MouseEvent,
|
|
29
|
+
type PointerEvent,
|
|
30
|
+
type ReactNode,
|
|
31
|
+
type WheelEvent,
|
|
32
|
+
useCallback,
|
|
33
|
+
useEffect,
|
|
34
|
+
useId,
|
|
35
|
+
useMemo,
|
|
36
|
+
useRef,
|
|
37
|
+
useState,
|
|
38
|
+
} from "react";
|
|
39
|
+
import {
|
|
40
|
+
useBrowserFrameStream,
|
|
41
|
+
type BrowserFrameWebSocketFactory,
|
|
42
|
+
} from "../hooks/use-browser-frame-stream";
|
|
43
|
+
import { useAttachedBrowsers } from "../hooks/use-attached-browsers";
|
|
44
|
+
import { useBrowserIdentities } from "../hooks/use-browser-identities";
|
|
45
|
+
import { useBrowserSession } from "../hooks/use-browser-session";
|
|
46
|
+
import { useBrowserSessions } from "../hooks/use-browser-sessions";
|
|
47
|
+
import { cn } from "../lib/cn";
|
|
48
|
+
import type { EmbeddedBrowserInteractionClientOverride } from "../session-context";
|
|
49
|
+
|
|
50
|
+
export type BrowserViewerNotification = {
|
|
51
|
+
kind: "error" | "info";
|
|
52
|
+
message: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type BrowserViewerProps = EmbeddedBrowserInteractionClientOverride & {
|
|
56
|
+
/** The selected OpenGeni agent/session. Peer BrowserSessions stay discoverable. */
|
|
57
|
+
sessionId: string;
|
|
58
|
+
enabled?: boolean | undefined;
|
|
59
|
+
className?: string | undefined;
|
|
60
|
+
onNotify?: ((notification: BrowserViewerNotification) => void) | undefined;
|
|
61
|
+
/** Tests/demos only. Production uses the browser's native WebSocket. */
|
|
62
|
+
webSocketFactory?: BrowserFrameWebSocketFactory | undefined;
|
|
63
|
+
renderEmpty?: ((create: () => void, creating: boolean) => ReactNode) | undefined;
|
|
64
|
+
/** Optional host capability for a headed managed browser. Browser-only
|
|
65
|
+
* embedders remain valid and create headless sessions instead. */
|
|
66
|
+
createLinkedComputer?:
|
|
67
|
+
| ((name: string) => Promise<Pick<ComputerSession, "id" | "placement">>)
|
|
68
|
+
| undefined;
|
|
69
|
+
/** Navigate to the exact linked ComputerSession; never a lookalike desktop. */
|
|
70
|
+
onOpenComputer?: ((computerSessionId: string) => void) | undefined;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type BrowserSelection = { sessionId: string; pinned: boolean } | null;
|
|
74
|
+
type BrowserLaunchChoice =
|
|
75
|
+
| { kind: "clean" }
|
|
76
|
+
| { kind: "profile"; identityId: string }
|
|
77
|
+
| { kind: "attached"; device: AttachedBrowserDevice };
|
|
78
|
+
type PointerStart = {
|
|
79
|
+
x: number;
|
|
80
|
+
y: number;
|
|
81
|
+
pointerId: number;
|
|
82
|
+
frame: BrowserFrame;
|
|
83
|
+
};
|
|
84
|
+
type BrowserResumeAttempt = {
|
|
85
|
+
operationId: string;
|
|
86
|
+
running: boolean;
|
|
87
|
+
terminal: boolean;
|
|
88
|
+
error: Error | null;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Complete browser-native session surface: workspace discovery, agent relevance,
|
|
93
|
+
* browser/tab switching, live frames, semantic fallback, direct human input,
|
|
94
|
+
* address navigation, diagnostics, and reconnect. Closing it never closes the browser.
|
|
95
|
+
*/
|
|
96
|
+
export function BrowserViewer({
|
|
97
|
+
sessionId,
|
|
98
|
+
enabled = true,
|
|
99
|
+
className,
|
|
100
|
+
onNotify,
|
|
101
|
+
webSocketFactory,
|
|
102
|
+
renderEmpty,
|
|
103
|
+
createLinkedComputer,
|
|
104
|
+
onOpenComputer,
|
|
105
|
+
...override
|
|
106
|
+
}: BrowserViewerProps) {
|
|
107
|
+
const registry = useBrowserSessions({ ...override, sessionId, enabled });
|
|
108
|
+
const attached = useAttachedBrowsers({ ...override, enabled });
|
|
109
|
+
const profiles = useBrowserIdentities({ ...override, enabled });
|
|
110
|
+
const createRegistryBrowser = registry.create;
|
|
111
|
+
const loadProfileRevisions = profiles.revisions;
|
|
112
|
+
const liveSessions = useMemo(
|
|
113
|
+
() => registry.sessions.filter((session) => isLiveBrowser(session)),
|
|
114
|
+
[registry.sessions],
|
|
115
|
+
);
|
|
116
|
+
const relevant = useMemo(
|
|
117
|
+
() => registry.relevantSessions.filter((session) => isLiveBrowser(session)),
|
|
118
|
+
[registry.relevantSessions],
|
|
119
|
+
);
|
|
120
|
+
const [selection, setSelection] = useState<BrowserSelection>(null);
|
|
121
|
+
const [creating, setCreating] = useState(false);
|
|
122
|
+
const [savingProfile, setSavingProfile] = useState(false);
|
|
123
|
+
const [baseRevisionOrdinal, setBaseRevisionOrdinal] = useState<number | null>(null);
|
|
124
|
+
const [resumeAttempt, setResumeAttempt] = useState<{
|
|
125
|
+
sessionId: string;
|
|
126
|
+
attempt: BrowserResumeAttempt;
|
|
127
|
+
} | null>(null);
|
|
128
|
+
const resumeAttemptsRef = useRef(new Map<string, BrowserResumeAttempt>());
|
|
129
|
+
const previousSessionIdRef = useRef(sessionId);
|
|
130
|
+
|
|
131
|
+
const notifyError = useCallback(
|
|
132
|
+
(cause: unknown, fallback: string) => {
|
|
133
|
+
const message = cause instanceof Error ? cause.message : fallback;
|
|
134
|
+
onNotify?.({ kind: "error", message });
|
|
135
|
+
},
|
|
136
|
+
[onNotify],
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
if (previousSessionIdRef.current !== sessionId) {
|
|
141
|
+
previousSessionIdRef.current = sessionId;
|
|
142
|
+
setSelection(null);
|
|
143
|
+
}
|
|
144
|
+
}, [sessionId]);
|
|
145
|
+
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
const selectedStillLive = liveSessions.some((session) => session.id === selection?.sessionId);
|
|
148
|
+
const preferred = relevant[0] ?? liveSessions[0] ?? null;
|
|
149
|
+
if (!preferred) {
|
|
150
|
+
if (selection) setSelection(null);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (!selectedStillLive || !selection?.pinned) {
|
|
154
|
+
if (selection?.sessionId !== preferred.id || selection.pinned) {
|
|
155
|
+
setSelection({ sessionId: preferred.id, pinned: false });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}, [liveSessions, relevant, selection]);
|
|
159
|
+
|
|
160
|
+
const selectedRegistrySession = useMemo(
|
|
161
|
+
() => liveSessions.find((session) => session.id === selection?.sessionId) ?? null,
|
|
162
|
+
[liveSessions, selection?.sessionId],
|
|
163
|
+
);
|
|
164
|
+
const controllerReady = selectedRegistrySession?.lifecycle === "active";
|
|
165
|
+
const resumeSession = registry.resume;
|
|
166
|
+
|
|
167
|
+
const wakeBrowser = useCallback(
|
|
168
|
+
async (session: BrowserSession, retry = false): Promise<void> => {
|
|
169
|
+
const current = resumeAttemptsRef.current.get(session.id);
|
|
170
|
+
if (current?.running || (!retry && current)) return;
|
|
171
|
+
const operationId = current && !current.terminal ? current.operationId : crypto.randomUUID();
|
|
172
|
+
const attempt: BrowserResumeAttempt = {
|
|
173
|
+
operationId,
|
|
174
|
+
running: true,
|
|
175
|
+
terminal: false,
|
|
176
|
+
error: null,
|
|
177
|
+
};
|
|
178
|
+
resumeAttemptsRef.current.set(session.id, attempt);
|
|
179
|
+
setResumeAttempt({ sessionId: session.id, attempt });
|
|
180
|
+
try {
|
|
181
|
+
const response = await resumeSession(session.id, operationId);
|
|
182
|
+
if (response.operation.state !== "completed") {
|
|
183
|
+
throw Object.assign(
|
|
184
|
+
new Error(response.operation.error?.message ?? "Browser could not reopen."),
|
|
185
|
+
{ terminal: true },
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
resumeAttemptsRef.current.delete(session.id);
|
|
189
|
+
setResumeAttempt((visible) => (visible?.sessionId === session.id ? null : visible));
|
|
190
|
+
} catch (cause) {
|
|
191
|
+
const error = cause instanceof Error ? cause : new Error(String(cause));
|
|
192
|
+
const failed: BrowserResumeAttempt = {
|
|
193
|
+
operationId,
|
|
194
|
+
running: false,
|
|
195
|
+
terminal:
|
|
196
|
+
typeof cause === "object" && cause !== null && "terminal" in cause
|
|
197
|
+
? cause.terminal === true
|
|
198
|
+
: false,
|
|
199
|
+
error,
|
|
200
|
+
};
|
|
201
|
+
resumeAttemptsRef.current.set(session.id, failed);
|
|
202
|
+
setResumeAttempt({ sessionId: session.id, attempt: failed });
|
|
203
|
+
notifyError(error, "Could not reopen this browser.");
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
[notifyError, resumeSession],
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
if (!selectedRegistrySession) return;
|
|
211
|
+
if (selectedRegistrySession.lifecycle === "active") {
|
|
212
|
+
resumeAttemptsRef.current.delete(selectedRegistrySession.id);
|
|
213
|
+
setResumeAttempt((visible) =>
|
|
214
|
+
visible?.sessionId === selectedRegistrySession.id ? null : visible,
|
|
215
|
+
);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (selectedRegistrySession.lifecycle === "suspended") {
|
|
219
|
+
void wakeBrowser(selectedRegistrySession);
|
|
220
|
+
}
|
|
221
|
+
}, [selectedRegistrySession, wakeBrowser]);
|
|
222
|
+
|
|
223
|
+
const browser = useBrowserSession({
|
|
224
|
+
...override,
|
|
225
|
+
browserSessionId: selection?.sessionId ?? null,
|
|
226
|
+
enabled: enabled && selection !== null && controllerReady,
|
|
227
|
+
});
|
|
228
|
+
const frames = useBrowserFrameStream({
|
|
229
|
+
...override,
|
|
230
|
+
browserSessionId: selection?.sessionId ?? null,
|
|
231
|
+
targetId: browser.selectedTarget?.id ?? null,
|
|
232
|
+
enabled: enabled && selection !== null && controllerReady && browser.selectedTarget !== null,
|
|
233
|
+
stream: { format: "jpeg", quality: 76, maxWidth: 1_920, maxHeight: 1_200 },
|
|
234
|
+
...(webSocketFactory ? { webSocketFactory } : {}),
|
|
235
|
+
});
|
|
236
|
+
const displayedFrame = frameMatchesObservation(frames.frame, browser.observation)
|
|
237
|
+
? frames.frame
|
|
238
|
+
: null;
|
|
239
|
+
const displayConnectionState =
|
|
240
|
+
frames.state === "live" && !displayedFrame ? "connecting" : frames.state;
|
|
241
|
+
const selectedProfile = useMemo(
|
|
242
|
+
() =>
|
|
243
|
+
profiles.identities.find(
|
|
244
|
+
(identity) =>
|
|
245
|
+
identity.id === (browser.session?.identityId ?? selectedRegistrySession?.identityId),
|
|
246
|
+
) ?? null,
|
|
247
|
+
[browser.session?.identityId, profiles.identities, selectedRegistrySession?.identityId],
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
const identityId = browser.session?.identityId ?? selectedRegistrySession?.identityId;
|
|
252
|
+
const revisionId = browser.session?.baseRevisionId ?? selectedRegistrySession?.baseRevisionId;
|
|
253
|
+
if (!identityId || !revisionId) {
|
|
254
|
+
setBaseRevisionOrdinal(null);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
let disposed = false;
|
|
258
|
+
setBaseRevisionOrdinal(null);
|
|
259
|
+
void loadProfileRevisions(identityId)
|
|
260
|
+
.then((response) => {
|
|
261
|
+
if (!disposed) {
|
|
262
|
+
setBaseRevisionOrdinal(
|
|
263
|
+
response.revisions.find((revision) => revision.id === revisionId)?.ordinal ?? null,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
.catch(() => undefined);
|
|
268
|
+
return () => {
|
|
269
|
+
disposed = true;
|
|
270
|
+
};
|
|
271
|
+
}, [
|
|
272
|
+
browser.session?.baseRevisionId,
|
|
273
|
+
browser.session?.identityId,
|
|
274
|
+
loadProfileRevisions,
|
|
275
|
+
selectedRegistrySession?.baseRevisionId,
|
|
276
|
+
selectedRegistrySession?.identityId,
|
|
277
|
+
]);
|
|
278
|
+
|
|
279
|
+
const createBrowser = useCallback(
|
|
280
|
+
(choice: BrowserLaunchChoice = { kind: "clean" }) => {
|
|
281
|
+
if (creating) return;
|
|
282
|
+
const identity =
|
|
283
|
+
choice.kind === "profile"
|
|
284
|
+
? profiles.identities.find((candidate) => candidate.id === choice.identityId)
|
|
285
|
+
: null;
|
|
286
|
+
const device = choice.kind === "attached" ? choice.device : null;
|
|
287
|
+
const browserName =
|
|
288
|
+
device?.profileLabel ?? device?.name ?? (identity ? `${identity.name} browser` : "Browser");
|
|
289
|
+
setCreating(true);
|
|
290
|
+
void (async () => {
|
|
291
|
+
const linkedComputer =
|
|
292
|
+
!device && createLinkedComputer
|
|
293
|
+
? await createLinkedComputer(`${browserName} computer`)
|
|
294
|
+
: null;
|
|
295
|
+
const response = await createRegistryBrowser({
|
|
296
|
+
sessionId,
|
|
297
|
+
name: browserName,
|
|
298
|
+
...(device
|
|
299
|
+
? {
|
|
300
|
+
headless: false,
|
|
301
|
+
placement: {
|
|
302
|
+
kind: "attached_device" as const,
|
|
303
|
+
deviceId: device.id,
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
: {}),
|
|
307
|
+
...(linkedComputer
|
|
308
|
+
? {
|
|
309
|
+
headless: false,
|
|
310
|
+
linkedComputerSessionId: linkedComputer.id,
|
|
311
|
+
placement: linkedComputer.placement,
|
|
312
|
+
}
|
|
313
|
+
: {}),
|
|
314
|
+
...(identity ? { identityId: identity.id } : {}),
|
|
315
|
+
});
|
|
316
|
+
setSelection({ sessionId: response.session.id, pinned: true });
|
|
317
|
+
})()
|
|
318
|
+
.catch((cause) => notifyError(cause, "Could not open a browser."))
|
|
319
|
+
.finally(() => setCreating(false));
|
|
320
|
+
},
|
|
321
|
+
[
|
|
322
|
+
createLinkedComputer,
|
|
323
|
+
createRegistryBrowser,
|
|
324
|
+
creating,
|
|
325
|
+
notifyError,
|
|
326
|
+
profiles.identities,
|
|
327
|
+
sessionId,
|
|
328
|
+
],
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const saveProfileVersion = useCallback(
|
|
332
|
+
async (newProfileName?: string): Promise<boolean> => {
|
|
333
|
+
const session = browser.session;
|
|
334
|
+
if (!session || savingProfile || !session.capabilities.identityPublication) return false;
|
|
335
|
+
setSavingProfile(true);
|
|
336
|
+
try {
|
|
337
|
+
let identity = selectedProfile;
|
|
338
|
+
if (!identity) {
|
|
339
|
+
const name = newProfileName?.trim() ?? "";
|
|
340
|
+
if (!name) return false;
|
|
341
|
+
identity = (await profiles.create({ name })).identity;
|
|
342
|
+
}
|
|
343
|
+
const response = await profiles.publish(session.id, {
|
|
344
|
+
identityId: identity.id,
|
|
345
|
+
expectedHeadGeneration: identity.headGeneration,
|
|
346
|
+
advanceDefault: true,
|
|
347
|
+
});
|
|
348
|
+
setBaseRevisionOrdinal(response.revision.ordinal);
|
|
349
|
+
await Promise.all([browser.refresh(), registry.refresh()]);
|
|
350
|
+
onNotify?.({
|
|
351
|
+
kind: "info",
|
|
352
|
+
message:
|
|
353
|
+
response.outcome === "saved_as_default"
|
|
354
|
+
? `${response.identity.name} version ${response.revision.ordinal} saved for future browsers.`
|
|
355
|
+
: `${response.identity.name} version ${response.revision.ordinal} saved. Another version remains the default.`,
|
|
356
|
+
});
|
|
357
|
+
return true;
|
|
358
|
+
} catch (cause) {
|
|
359
|
+
notifyError(cause, "Could not save this browser profile.");
|
|
360
|
+
return false;
|
|
361
|
+
} finally {
|
|
362
|
+
setSavingProfile(false);
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
[browser, notifyError, onNotify, profiles, registry, savingProfile, selectedProfile],
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
if (!enabled) return null;
|
|
369
|
+
if (registry.loading && liveSessions.length === 0) {
|
|
370
|
+
return (
|
|
371
|
+
<BrowserNotice
|
|
372
|
+
icon={<LoaderCircleIcon className="animate-spin" />}
|
|
373
|
+
text="Finding browsers…"
|
|
374
|
+
{...(className ? { className } : {})}
|
|
375
|
+
/>
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
if (liveSessions.length === 0) {
|
|
379
|
+
if (renderEmpty) return <>{renderEmpty(() => createBrowser(), creating)}</>;
|
|
380
|
+
return (
|
|
381
|
+
<div
|
|
382
|
+
className={cn("flex h-full min-h-0 items-center justify-center bg-og-bg p-6", className)}
|
|
383
|
+
>
|
|
384
|
+
<div className="max-w-sm text-center">
|
|
385
|
+
<span className="mx-auto grid size-10 place-items-center rounded-og-md border border-og-border bg-og-surface-1 text-og-muted">
|
|
386
|
+
<Globe2Icon className="size-4.5" />
|
|
387
|
+
</span>
|
|
388
|
+
<p className="mt-3 text-og-menu font-medium text-og-fg">No browser open</p>
|
|
389
|
+
<p className="mt-1 text-og-control leading-5 text-og-muted">
|
|
390
|
+
A browser appears here when this agent—or another agent in the workspace—opens one.
|
|
391
|
+
</p>
|
|
392
|
+
<BrowserLaunchMenu
|
|
393
|
+
attachedDevices={attached.devices}
|
|
394
|
+
identities={profiles.identities}
|
|
395
|
+
creating={creating}
|
|
396
|
+
onCreate={createBrowser}
|
|
397
|
+
prominent
|
|
398
|
+
/>
|
|
399
|
+
{registry.error ? (
|
|
400
|
+
<p className="mt-3 text-og-control text-og-status-error">{registry.error.message}</p>
|
|
401
|
+
) : null}
|
|
402
|
+
</div>
|
|
403
|
+
</div>
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return (
|
|
408
|
+
<div className={cn("flex h-full min-h-0 flex-col overflow-hidden bg-og-bg", className)}>
|
|
409
|
+
<BrowserToolbar
|
|
410
|
+
sessions={liveSessions}
|
|
411
|
+
relevantSessionIds={new Set(relevant.map((session) => session.id))}
|
|
412
|
+
selectedSessionId={selection?.sessionId ?? null}
|
|
413
|
+
identities={profiles.identities}
|
|
414
|
+
attachedDevices={attached.devices}
|
|
415
|
+
selectedProfile={selectedProfile}
|
|
416
|
+
baseRevisionOrdinal={baseRevisionOrdinal}
|
|
417
|
+
creating={creating}
|
|
418
|
+
savingProfile={savingProfile}
|
|
419
|
+
refreshing={registry.refreshing || attached.refreshing}
|
|
420
|
+
onSelect={(browserSessionId) => setSelection({ sessionId: browserSessionId, pinned: true })}
|
|
421
|
+
onFollow={() => {
|
|
422
|
+
const preferred = relevant[0];
|
|
423
|
+
if (preferred) setSelection({ sessionId: preferred.id, pinned: false });
|
|
424
|
+
}}
|
|
425
|
+
onCreate={createBrowser}
|
|
426
|
+
onSaveProfile={saveProfileVersion}
|
|
427
|
+
onRefresh={() => void Promise.all([registry.refresh(), attached.refresh()])}
|
|
428
|
+
/>
|
|
429
|
+
{selectedRegistrySession && !controllerReady ? (
|
|
430
|
+
<BrowserLifecyclePanel
|
|
431
|
+
session={selectedRegistrySession}
|
|
432
|
+
attempt={
|
|
433
|
+
resumeAttempt?.sessionId === selectedRegistrySession.id ? resumeAttempt.attempt : null
|
|
434
|
+
}
|
|
435
|
+
onResume={() => void wakeBrowser(selectedRegistrySession, true)}
|
|
436
|
+
/>
|
|
437
|
+
) : (
|
|
438
|
+
<>
|
|
439
|
+
<BrowserTabs
|
|
440
|
+
targets={browser.targets}
|
|
441
|
+
selectedTargetId={browser.selectedTarget?.id ?? null}
|
|
442
|
+
mutating={browser.mutating || savingProfile}
|
|
443
|
+
onSelect={(targetId) =>
|
|
444
|
+
void browser
|
|
445
|
+
.selectTarget(targetId)
|
|
446
|
+
.catch((cause) => notifyError(cause, "Could not switch tabs."))
|
|
447
|
+
}
|
|
448
|
+
onClose={(targetId) =>
|
|
449
|
+
void browser
|
|
450
|
+
.closeTarget(targetId)
|
|
451
|
+
.catch((cause) => notifyError(cause, "Could not close the tab."))
|
|
452
|
+
}
|
|
453
|
+
onOpen={() =>
|
|
454
|
+
void browser
|
|
455
|
+
.openTarget()
|
|
456
|
+
.catch((cause) => notifyError(cause, "Could not open a tab."))
|
|
457
|
+
}
|
|
458
|
+
/>
|
|
459
|
+
<BrowserAddressBar
|
|
460
|
+
target={browser.selectedTarget}
|
|
461
|
+
loading={browser.loading || browser.mutating || savingProfile}
|
|
462
|
+
onNavigate={(url) =>
|
|
463
|
+
void browser
|
|
464
|
+
.act({ type: "navigate", url })
|
|
465
|
+
.catch((cause) => notifyError(cause, "Could not navigate."))
|
|
466
|
+
}
|
|
467
|
+
onReload={() => {
|
|
468
|
+
const url = browser.selectedTarget?.url;
|
|
469
|
+
if (url)
|
|
470
|
+
void browser
|
|
471
|
+
.act({ type: "navigate", url })
|
|
472
|
+
.catch((cause) => notifyError(cause, "Could not reload."));
|
|
473
|
+
}}
|
|
474
|
+
/>
|
|
475
|
+
<BrowserViewport
|
|
476
|
+
frame={displayedFrame}
|
|
477
|
+
connectionState={displayConnectionState}
|
|
478
|
+
connectionError={frames.error}
|
|
479
|
+
observation={browser.observation}
|
|
480
|
+
mutating={browser.mutating || savingProfile}
|
|
481
|
+
activityLabel={savingProfile ? "Saving browser version…" : undefined}
|
|
482
|
+
onAction={(action, frame) =>
|
|
483
|
+
(frame ? browser.actFromFrame(action, frame) : browser.act(action)).then(
|
|
484
|
+
() => undefined,
|
|
485
|
+
)
|
|
486
|
+
}
|
|
487
|
+
onReconnect={frames.reconnect}
|
|
488
|
+
onError={(cause) => notifyError(cause, "Browser input failed.")}
|
|
489
|
+
/>
|
|
490
|
+
<BrowserStatusBar
|
|
491
|
+
session={browser.session}
|
|
492
|
+
profile={selectedProfile}
|
|
493
|
+
target={browser.selectedTarget}
|
|
494
|
+
observation={browser.observation}
|
|
495
|
+
connectionState={displayConnectionState}
|
|
496
|
+
refreshing={registry.refreshing}
|
|
497
|
+
onOpenComputer={
|
|
498
|
+
browser.session?.linkedComputerSessionId && onOpenComputer
|
|
499
|
+
? () => {
|
|
500
|
+
const computerSessionId = browser.session?.linkedComputerSessionId;
|
|
501
|
+
if (computerSessionId) onOpenComputer(computerSessionId);
|
|
502
|
+
}
|
|
503
|
+
: undefined
|
|
504
|
+
}
|
|
505
|
+
onDiagnostics={() =>
|
|
506
|
+
void browser
|
|
507
|
+
.diagnostics({ limit: 100 })
|
|
508
|
+
.then((batch) => {
|
|
509
|
+
const errors = batch.entries.filter((entry) => entry.level === "error").length;
|
|
510
|
+
onNotify?.({
|
|
511
|
+
kind: "info",
|
|
512
|
+
message: batch.entries.length
|
|
513
|
+
? `${batch.entries.length} browser diagnostic${batch.entries.length === 1 ? "" : "s"} (${errors} errors).`
|
|
514
|
+
: "No browser diagnostics.",
|
|
515
|
+
});
|
|
516
|
+
})
|
|
517
|
+
.catch((cause) => notifyError(cause, "Could not load browser diagnostics."))
|
|
518
|
+
}
|
|
519
|
+
/>
|
|
520
|
+
</>
|
|
521
|
+
)}
|
|
522
|
+
</div>
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function BrowserLifecyclePanel(props: {
|
|
527
|
+
session: BrowserSession;
|
|
528
|
+
attempt: BrowserResumeAttempt | null;
|
|
529
|
+
onResume: () => void;
|
|
530
|
+
}) {
|
|
531
|
+
const failed = props.attempt?.error ?? null;
|
|
532
|
+
const label = failed
|
|
533
|
+
? "Browser could not reopen"
|
|
534
|
+
: props.session.lifecycle === "suspending"
|
|
535
|
+
? "Saving browser state…"
|
|
536
|
+
: props.session.lifecycle === "restoring" || props.attempt?.running
|
|
537
|
+
? "Opening browser…"
|
|
538
|
+
: props.session.lifecycle === "starting"
|
|
539
|
+
? "Starting browser…"
|
|
540
|
+
: "Browser is sleeping";
|
|
541
|
+
const detail = failed
|
|
542
|
+
? failed.message
|
|
543
|
+
: props.session.lifecycle === "suspended"
|
|
544
|
+
? "Its private state is saved. Opening it creates a fresh, isolated browser process."
|
|
545
|
+
: "The browser will reconnect here when it is ready.";
|
|
546
|
+
return (
|
|
547
|
+
<div className="grid min-h-0 flex-1 place-items-center bg-og-bg p-6 text-center">
|
|
548
|
+
<div className="max-w-sm">
|
|
549
|
+
<span className="mx-auto grid size-10 place-items-center rounded-og-md border border-og-border bg-og-surface-1 text-og-muted">
|
|
550
|
+
{failed ? (
|
|
551
|
+
<CircleAlertIcon className="size-4.5 text-og-status-error" />
|
|
552
|
+
) : (
|
|
553
|
+
<LoaderCircleIcon className="size-4.5 animate-spin" />
|
|
554
|
+
)}
|
|
555
|
+
</span>
|
|
556
|
+
<p className="mt-3 text-og-menu font-medium text-og-fg">{label}</p>
|
|
557
|
+
<p className="mt-1 text-og-control leading-5 text-og-muted">{detail}</p>
|
|
558
|
+
{failed || (props.session.lifecycle === "suspended" && !props.attempt?.running) ? (
|
|
559
|
+
<button
|
|
560
|
+
type="button"
|
|
561
|
+
onClick={props.onResume}
|
|
562
|
+
className="mt-4 inline-flex h-8 items-center 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"
|
|
563
|
+
>
|
|
564
|
+
Open browser
|
|
565
|
+
</button>
|
|
566
|
+
) : null}
|
|
567
|
+
</div>
|
|
568
|
+
</div>
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function BrowserToolbar(props: {
|
|
573
|
+
sessions: BrowserSession[];
|
|
574
|
+
relevantSessionIds: Set<string>;
|
|
575
|
+
selectedSessionId: string | null;
|
|
576
|
+
attachedDevices: AttachedBrowserDevice[];
|
|
577
|
+
identities: BrowserIdentity[];
|
|
578
|
+
selectedProfile: BrowserIdentity | null;
|
|
579
|
+
baseRevisionOrdinal: number | null;
|
|
580
|
+
creating: boolean;
|
|
581
|
+
savingProfile: boolean;
|
|
582
|
+
refreshing: boolean;
|
|
583
|
+
onSelect: (id: string) => void;
|
|
584
|
+
onFollow: () => void;
|
|
585
|
+
onCreate: (choice?: BrowserLaunchChoice) => void;
|
|
586
|
+
onSaveProfile: (newProfileName?: string) => Promise<boolean>;
|
|
587
|
+
onRefresh: () => void;
|
|
588
|
+
}) {
|
|
589
|
+
const detailsRef = useRef<HTMLDetailsElement | null>(null);
|
|
590
|
+
const selected = props.sessions.find((session) => session.id === props.selectedSessionId);
|
|
591
|
+
const current = props.sessions.filter((session) => props.relevantSessionIds.has(session.id));
|
|
592
|
+
const others = props.sessions.filter((session) => !props.relevantSessionIds.has(session.id));
|
|
593
|
+
const choose = (id: string) => {
|
|
594
|
+
props.onSelect(id);
|
|
595
|
+
detailsRef.current?.removeAttribute("open");
|
|
596
|
+
};
|
|
597
|
+
return (
|
|
598
|
+
<div className="flex h-10 shrink-0 items-center gap-1.5 border-b border-og-border bg-og-surface-0 px-2">
|
|
599
|
+
<details ref={detailsRef} className="relative min-w-0">
|
|
600
|
+
<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">
|
|
601
|
+
<Globe2Icon className="size-3.5 shrink-0 text-og-muted" />
|
|
602
|
+
<span className="truncate font-medium">{selected?.name ?? "Browser"}</span>
|
|
603
|
+
<ChevronDownIcon className="size-3 shrink-0 text-og-subtle" />
|
|
604
|
+
</summary>
|
|
605
|
+
<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">
|
|
606
|
+
<BrowserSessionGroup
|
|
607
|
+
label="Current agent"
|
|
608
|
+
sessions={current}
|
|
609
|
+
identities={props.identities}
|
|
610
|
+
selectedId={props.selectedSessionId}
|
|
611
|
+
onSelect={choose}
|
|
612
|
+
/>
|
|
613
|
+
<BrowserSessionGroup
|
|
614
|
+
label="Other agents"
|
|
615
|
+
sessions={others}
|
|
616
|
+
identities={props.identities}
|
|
617
|
+
selectedId={props.selectedSessionId}
|
|
618
|
+
onSelect={choose}
|
|
619
|
+
/>
|
|
620
|
+
<div className="mt-1 flex gap-1 border-t border-og-border pt-1">
|
|
621
|
+
{current.length > 0 ? (
|
|
622
|
+
<MenuButton onClick={props.onFollow}>Follow agent</MenuButton>
|
|
623
|
+
) : null}
|
|
624
|
+
<MenuButton onClick={() => props.onCreate()} disabled={props.creating}>
|
|
625
|
+
<PlusIcon className="size-3.5" /> New browser
|
|
626
|
+
</MenuButton>
|
|
627
|
+
</div>
|
|
628
|
+
</div>
|
|
629
|
+
</details>
|
|
630
|
+
<span className="min-w-0 flex-1 truncate text-og-xs text-og-subtle">
|
|
631
|
+
{placementLabel(selected)}
|
|
632
|
+
</span>
|
|
633
|
+
<BrowserProfileMenu
|
|
634
|
+
session={selected ?? null}
|
|
635
|
+
identity={props.selectedProfile}
|
|
636
|
+
baseRevisionOrdinal={props.baseRevisionOrdinal}
|
|
637
|
+
saving={props.savingProfile}
|
|
638
|
+
onSave={props.onSaveProfile}
|
|
639
|
+
/>
|
|
640
|
+
<button
|
|
641
|
+
type="button"
|
|
642
|
+
onClick={props.onRefresh}
|
|
643
|
+
className="grid size-7 place-items-center rounded-og-sm text-og-muted transition hover:bg-og-surface-2 hover:text-og-fg"
|
|
644
|
+
aria-label="Refresh browsers"
|
|
645
|
+
>
|
|
646
|
+
<RefreshCwIcon className={cn("size-3.5", props.refreshing && "animate-spin")} />
|
|
647
|
+
</button>
|
|
648
|
+
<BrowserLaunchMenu
|
|
649
|
+
attachedDevices={props.attachedDevices}
|
|
650
|
+
identities={props.identities}
|
|
651
|
+
creating={props.creating}
|
|
652
|
+
onCreate={props.onCreate}
|
|
653
|
+
/>
|
|
654
|
+
</div>
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function BrowserSessionGroup(props: {
|
|
659
|
+
label: string;
|
|
660
|
+
sessions: BrowserSession[];
|
|
661
|
+
identities: BrowserIdentity[];
|
|
662
|
+
selectedId: string | null;
|
|
663
|
+
onSelect: (id: string) => void;
|
|
664
|
+
}) {
|
|
665
|
+
if (props.sessions.length === 0) return null;
|
|
666
|
+
return (
|
|
667
|
+
<div className="py-1">
|
|
668
|
+
<p className="px-2 pb-1 text-[10px] font-medium uppercase tracking-[0.12em] text-og-subtle">
|
|
669
|
+
{props.label}
|
|
670
|
+
</p>
|
|
671
|
+
{props.sessions.map((session) => {
|
|
672
|
+
const identity = props.identities.find((candidate) => candidate.id === session.identityId);
|
|
673
|
+
return (
|
|
674
|
+
<button
|
|
675
|
+
key={session.id}
|
|
676
|
+
type="button"
|
|
677
|
+
onClick={() => props.onSelect(session.id)}
|
|
678
|
+
className={cn(
|
|
679
|
+
"flex w-full items-center gap-2 rounded-og-sm px-2 py-1.5 text-left transition hover:bg-og-surface-2",
|
|
680
|
+
session.id === props.selectedId && "bg-og-surface-2",
|
|
681
|
+
)}
|
|
682
|
+
>
|
|
683
|
+
<span
|
|
684
|
+
className={cn(
|
|
685
|
+
"size-1.5 rounded-full",
|
|
686
|
+
session.lifecycle === "active" ? "bg-og-status-running" : "bg-og-muted",
|
|
687
|
+
)}
|
|
688
|
+
/>
|
|
689
|
+
<span className="min-w-0 flex-1">
|
|
690
|
+
<span className="block truncate text-og-control text-og-fg">{session.name}</span>
|
|
691
|
+
<span className="block truncate text-og-xs text-og-subtle">
|
|
692
|
+
{identity ? `${identity.name} · ` : ""}
|
|
693
|
+
{placementLabel(session)}
|
|
694
|
+
</span>
|
|
695
|
+
</span>
|
|
696
|
+
</button>
|
|
697
|
+
);
|
|
698
|
+
})}
|
|
699
|
+
</div>
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function BrowserLaunchMenu(props: {
|
|
704
|
+
attachedDevices: AttachedBrowserDevice[];
|
|
705
|
+
identities: BrowserIdentity[];
|
|
706
|
+
creating: boolean;
|
|
707
|
+
onCreate: (choice?: BrowserLaunchChoice) => void;
|
|
708
|
+
prominent?: boolean;
|
|
709
|
+
}) {
|
|
710
|
+
const detailsRef = useRef<HTMLDetailsElement | null>(null);
|
|
711
|
+
const choose = (choice?: BrowserLaunchChoice) => {
|
|
712
|
+
detailsRef.current?.removeAttribute("open");
|
|
713
|
+
props.onCreate(choice);
|
|
714
|
+
};
|
|
715
|
+
return (
|
|
716
|
+
<details ref={detailsRef} className={cn("relative", props.prominent && "mt-4 inline-block")}>
|
|
717
|
+
<summary
|
|
718
|
+
onClick={(event) => {
|
|
719
|
+
if (props.creating) event.preventDefault();
|
|
720
|
+
}}
|
|
721
|
+
className={cn(
|
|
722
|
+
"cursor-pointer list-none items-center justify-center gap-1.5 text-og-muted transition hover:bg-og-surface-2 hover:text-og-fg [&::-webkit-details-marker]:hidden",
|
|
723
|
+
props.prominent
|
|
724
|
+
? "inline-flex h-8 rounded-og-sm border border-og-border bg-og-surface-1 px-3 text-og-control font-medium text-og-fg"
|
|
725
|
+
: "grid size-7 place-items-center rounded-og-sm",
|
|
726
|
+
props.creating && "pointer-events-none opacity-50",
|
|
727
|
+
)}
|
|
728
|
+
aria-label="New browser"
|
|
729
|
+
>
|
|
730
|
+
{props.creating ? (
|
|
731
|
+
<LoaderCircleIcon className="size-3.5 animate-spin" />
|
|
732
|
+
) : (
|
|
733
|
+
<PlusIcon className="size-3.5" />
|
|
734
|
+
)}
|
|
735
|
+
{props.prominent ? "New browser" : null}
|
|
736
|
+
{props.prominent ? <ChevronDownIcon className="size-3" /> : null}
|
|
737
|
+
</summary>
|
|
738
|
+
<div
|
|
739
|
+
className={cn(
|
|
740
|
+
"absolute z-40 w-72 overflow-hidden rounded-og-md border border-og-border bg-og-surface-1 p-1 text-left shadow-xl",
|
|
741
|
+
props.prominent ? "left-1/2 top-10 -translate-x-1/2" : "right-0 top-8",
|
|
742
|
+
)}
|
|
743
|
+
>
|
|
744
|
+
{props.attachedDevices.length > 0 ? (
|
|
745
|
+
<div className="pb-1">
|
|
746
|
+
<p className="px-2 pb-1 pt-1 text-[10px] font-medium uppercase tracking-[0.12em] text-og-subtle">
|
|
747
|
+
Connected Chrome
|
|
748
|
+
</p>
|
|
749
|
+
{props.attachedDevices.map((device) => (
|
|
750
|
+
<button
|
|
751
|
+
key={device.id}
|
|
752
|
+
type="button"
|
|
753
|
+
onClick={() => choose({ kind: "attached", device })}
|
|
754
|
+
className="flex w-full items-center gap-2 rounded-og-sm px-2 py-2 text-left transition hover:bg-og-surface-2"
|
|
755
|
+
>
|
|
756
|
+
<MonitorIcon className="size-3.5 shrink-0 text-og-muted" />
|
|
757
|
+
<span className="min-w-0 flex-1">
|
|
758
|
+
<span className="block truncate text-og-control text-og-fg">
|
|
759
|
+
{device.profileLabel ?? device.name}
|
|
760
|
+
</span>
|
|
761
|
+
<span className="block truncate text-og-xs text-og-subtle">
|
|
762
|
+
{device.browserName} · {device.tabCount} open{" "}
|
|
763
|
+
{device.tabCount === 1 ? "tab" : "tabs"}
|
|
764
|
+
</span>
|
|
765
|
+
</span>
|
|
766
|
+
<span className="size-1.5 shrink-0 rounded-full bg-og-status-running" />
|
|
767
|
+
</button>
|
|
768
|
+
))}
|
|
769
|
+
</div>
|
|
770
|
+
) : null}
|
|
771
|
+
<p
|
|
772
|
+
className={cn(
|
|
773
|
+
"px-2 pb-1 pt-1 text-[10px] font-medium uppercase tracking-[0.12em] text-og-subtle",
|
|
774
|
+
props.attachedDevices.length > 0 && "border-t border-og-border",
|
|
775
|
+
)}
|
|
776
|
+
>
|
|
777
|
+
Open isolated browser
|
|
778
|
+
</p>
|
|
779
|
+
<button
|
|
780
|
+
type="button"
|
|
781
|
+
onClick={() => choose({ kind: "clean" })}
|
|
782
|
+
className="flex w-full items-center gap-2 rounded-og-sm px-2 py-2 text-left transition hover:bg-og-surface-2"
|
|
783
|
+
>
|
|
784
|
+
<Globe2Icon className="size-3.5 text-og-muted" />
|
|
785
|
+
<span>
|
|
786
|
+
<span className="block text-og-control text-og-fg">Clean browser</span>
|
|
787
|
+
<span className="block text-og-xs text-og-subtle">No saved profile</span>
|
|
788
|
+
</span>
|
|
789
|
+
</button>
|
|
790
|
+
{props.identities.length > 0 ? (
|
|
791
|
+
<div className="mt-1 border-t border-og-border pt-1">
|
|
792
|
+
{props.identities.map((identity) => (
|
|
793
|
+
<button
|
|
794
|
+
key={identity.id}
|
|
795
|
+
type="button"
|
|
796
|
+
onClick={() => choose({ kind: "profile", identityId: identity.id })}
|
|
797
|
+
className="flex w-full items-center gap-2 rounded-og-sm px-2 py-2 text-left transition hover:bg-og-surface-2"
|
|
798
|
+
>
|
|
799
|
+
<UserRoundIcon className="size-3.5 text-og-muted" />
|
|
800
|
+
<span className="min-w-0">
|
|
801
|
+
<span className="block truncate text-og-control text-og-fg">{identity.name}</span>
|
|
802
|
+
<span className="block text-og-xs text-og-subtle">
|
|
803
|
+
{identity.defaultRevisionId
|
|
804
|
+
? `${identity.revisionCount} saved version${identity.revisionCount === 1 ? "" : "s"}`
|
|
805
|
+
: "Ready for first sign-in"}
|
|
806
|
+
</span>
|
|
807
|
+
</span>
|
|
808
|
+
</button>
|
|
809
|
+
))}
|
|
810
|
+
</div>
|
|
811
|
+
) : null}
|
|
812
|
+
</div>
|
|
813
|
+
</details>
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function BrowserProfileMenu(props: {
|
|
818
|
+
session: BrowserSession | null;
|
|
819
|
+
identity: BrowserIdentity | null;
|
|
820
|
+
baseRevisionOrdinal: number | null;
|
|
821
|
+
saving: boolean;
|
|
822
|
+
onSave: (newProfileName?: string) => Promise<boolean>;
|
|
823
|
+
}) {
|
|
824
|
+
const detailsRef = useRef<HTMLDetailsElement | null>(null);
|
|
825
|
+
const nameInputId = useId();
|
|
826
|
+
const [name, setName] = useState("");
|
|
827
|
+
const attached = props.session?.placement.kind === "attached_device";
|
|
828
|
+
const canSave = props.session?.capabilities.identityPublication ?? false;
|
|
829
|
+
const save = async (newProfileName?: string) => {
|
|
830
|
+
if (await props.onSave(newProfileName)) {
|
|
831
|
+
setName("");
|
|
832
|
+
detailsRef.current?.removeAttribute("open");
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
const versionLabel = attached
|
|
836
|
+
? "live"
|
|
837
|
+
: props.identity
|
|
838
|
+
? props.baseRevisionOrdinal
|
|
839
|
+
? `v${props.baseRevisionOrdinal}`
|
|
840
|
+
: props.session?.baseRevisionId
|
|
841
|
+
? "saved"
|
|
842
|
+
: "unsaved"
|
|
843
|
+
: "temporary";
|
|
844
|
+
return (
|
|
845
|
+
<details ref={detailsRef} className="relative min-w-0">
|
|
846
|
+
<summary className="flex h-7 max-w-40 cursor-pointer list-none items-center gap-1.5 rounded-og-sm px-2 text-og-xs text-og-muted transition hover:bg-og-surface-2 hover:text-og-fg [&::-webkit-details-marker]:hidden">
|
|
847
|
+
{props.saving ? (
|
|
848
|
+
<LoaderCircleIcon className="size-3 animate-spin" />
|
|
849
|
+
) : attached ? (
|
|
850
|
+
<MonitorIcon className="size-3" />
|
|
851
|
+
) : (
|
|
852
|
+
<UserRoundIcon className="size-3" />
|
|
853
|
+
)}
|
|
854
|
+
<span className="truncate">
|
|
855
|
+
{attached ? props.session?.name : (props.identity?.name ?? "Temporary")}
|
|
856
|
+
</span>
|
|
857
|
+
{attached || props.identity ? (
|
|
858
|
+
<span className="shrink-0 text-og-subtle">· {versionLabel}</span>
|
|
859
|
+
) : null}
|
|
860
|
+
<ChevronDownIcon className="size-3 shrink-0 text-og-subtle" />
|
|
861
|
+
</summary>
|
|
862
|
+
<div className="absolute right-0 top-8 z-40 w-72 rounded-og-md border border-og-border bg-og-surface-1 p-3 shadow-xl">
|
|
863
|
+
<div className="flex items-start gap-2">
|
|
864
|
+
<span className="grid size-7 shrink-0 place-items-center rounded-og-sm bg-og-surface-2 text-og-muted">
|
|
865
|
+
{attached ? (
|
|
866
|
+
<MonitorIcon className="size-3.5" />
|
|
867
|
+
) : (
|
|
868
|
+
<UserRoundIcon className="size-3.5" />
|
|
869
|
+
)}
|
|
870
|
+
</span>
|
|
871
|
+
<div className="min-w-0">
|
|
872
|
+
<p className="truncate text-og-control font-medium text-og-fg">
|
|
873
|
+
{attached ? props.session?.name : (props.identity?.name ?? "Temporary browser")}
|
|
874
|
+
</p>
|
|
875
|
+
<p className="mt-0.5 text-og-xs leading-4 text-og-subtle">
|
|
876
|
+
{attached
|
|
877
|
+
? "This session drives the existing Chrome profile and its current login state."
|
|
878
|
+
: props.identity
|
|
879
|
+
? props.baseRevisionOrdinal
|
|
880
|
+
? `Started from version ${props.baseRevisionOrdinal}.`
|
|
881
|
+
: "Not saved yet."
|
|
882
|
+
: "This browser is not reusable yet."}
|
|
883
|
+
</p>
|
|
884
|
+
</div>
|
|
885
|
+
</div>
|
|
886
|
+
{canSave ? (
|
|
887
|
+
props.identity ? (
|
|
888
|
+
<button
|
|
889
|
+
type="button"
|
|
890
|
+
onClick={() => void save()}
|
|
891
|
+
disabled={props.saving}
|
|
892
|
+
className="mt-3 flex h-8 w-full items-center justify-center gap-1.5 rounded-og-sm border border-og-border bg-og-surface-2 px-3 text-og-control font-medium text-og-fg transition hover:bg-og-surface-3 disabled:opacity-50"
|
|
893
|
+
>
|
|
894
|
+
{props.saving ? (
|
|
895
|
+
<LoaderCircleIcon className="size-3.5 animate-spin" />
|
|
896
|
+
) : (
|
|
897
|
+
<SaveIcon className="size-3.5" />
|
|
898
|
+
)}
|
|
899
|
+
{props.saving ? "Saving browser version…" : "Save new version"}
|
|
900
|
+
</button>
|
|
901
|
+
) : (
|
|
902
|
+
<form
|
|
903
|
+
className="mt-3"
|
|
904
|
+
onSubmit={(event) => {
|
|
905
|
+
event.preventDefault();
|
|
906
|
+
if (name.trim()) void save(name);
|
|
907
|
+
}}
|
|
908
|
+
>
|
|
909
|
+
<label className="text-og-xs font-medium text-og-muted" htmlFor={nameInputId}>
|
|
910
|
+
Profile name
|
|
911
|
+
</label>
|
|
912
|
+
<div className="mt-1 flex gap-1.5">
|
|
913
|
+
<input
|
|
914
|
+
id={nameInputId}
|
|
915
|
+
value={name}
|
|
916
|
+
onInput={(event) => setName(event.currentTarget.value)}
|
|
917
|
+
maxLength={200}
|
|
918
|
+
placeholder="Work"
|
|
919
|
+
className="h-8 min-w-0 flex-1 rounded-og-sm border border-og-border bg-og-bg px-2 text-og-control text-og-fg placeholder:text-og-subtle focus:border-og-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-accent"
|
|
920
|
+
/>
|
|
921
|
+
<button
|
|
922
|
+
type="submit"
|
|
923
|
+
disabled={!name.trim() || props.saving}
|
|
924
|
+
className="inline-flex h-8 items-center gap-1 rounded-og-sm bg-og-accent px-2.5 text-og-control font-medium text-white transition hover:opacity-90 disabled:opacity-40"
|
|
925
|
+
>
|
|
926
|
+
{props.saving ? (
|
|
927
|
+
<LoaderCircleIcon className="size-3.5 animate-spin" />
|
|
928
|
+
) : (
|
|
929
|
+
<SaveIcon className="size-3.5" />
|
|
930
|
+
)}
|
|
931
|
+
Save
|
|
932
|
+
</button>
|
|
933
|
+
</div>
|
|
934
|
+
</form>
|
|
935
|
+
)
|
|
936
|
+
) : (
|
|
937
|
+
<p className="mt-3 rounded-og-sm bg-og-surface-2 px-2.5 py-2 text-og-xs text-og-subtle">
|
|
938
|
+
{attached
|
|
939
|
+
? "Chrome keeps this profile's state directly; OpenGeni does not copy it automatically."
|
|
940
|
+
: "This browser cannot save reusable profile state."}
|
|
941
|
+
</p>
|
|
942
|
+
)}
|
|
943
|
+
{canSave ? (
|
|
944
|
+
<p className="mt-2 text-[10px] leading-4 text-og-subtle">
|
|
945
|
+
Saving briefly restarts this browser. Other open browsers are unchanged.
|
|
946
|
+
</p>
|
|
947
|
+
) : null}
|
|
948
|
+
</div>
|
|
949
|
+
</details>
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function MenuButton(props: { children: ReactNode; onClick: () => void; disabled?: boolean }) {
|
|
954
|
+
return (
|
|
955
|
+
<button
|
|
956
|
+
type="button"
|
|
957
|
+
onClick={props.onClick}
|
|
958
|
+
disabled={props.disabled}
|
|
959
|
+
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"
|
|
960
|
+
>
|
|
961
|
+
{props.children}
|
|
962
|
+
</button>
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function BrowserTabs(props: {
|
|
967
|
+
targets: BrowserTarget[];
|
|
968
|
+
selectedTargetId: string | null;
|
|
969
|
+
mutating: boolean;
|
|
970
|
+
onSelect: (id: string) => void;
|
|
971
|
+
onClose: (id: string) => void;
|
|
972
|
+
onOpen: () => void;
|
|
973
|
+
}) {
|
|
974
|
+
const pages = props.targets.filter((target) => target.kind === "page" || target.kind === "popup");
|
|
975
|
+
return (
|
|
976
|
+
<div className="flex h-9 shrink-0 items-end gap-px overflow-x-auto border-b border-og-border bg-og-surface-0 px-1 pt-1">
|
|
977
|
+
{pages.map((target) => (
|
|
978
|
+
<div
|
|
979
|
+
key={target.id}
|
|
980
|
+
className={cn(
|
|
981
|
+
"group flex h-8 min-w-24 max-w-48 items-center rounded-t-og-sm border border-b-0 border-transparent px-2",
|
|
982
|
+
target.id === props.selectedTargetId
|
|
983
|
+
? "border-og-border bg-og-bg text-og-fg"
|
|
984
|
+
: "text-og-muted hover:bg-og-surface-1",
|
|
985
|
+
)}
|
|
986
|
+
>
|
|
987
|
+
<button
|
|
988
|
+
type="button"
|
|
989
|
+
onClick={() => props.onSelect(target.id)}
|
|
990
|
+
className="min-w-0 flex-1 truncate text-left text-og-control"
|
|
991
|
+
>
|
|
992
|
+
{target.title || shortUrl(target.url)}
|
|
993
|
+
</button>
|
|
994
|
+
<button
|
|
995
|
+
type="button"
|
|
996
|
+
onClick={() => props.onClose(target.id)}
|
|
997
|
+
disabled={props.mutating}
|
|
998
|
+
className="ml-1 grid size-4 shrink-0 place-items-center rounded opacity-0 transition hover:bg-og-surface-3 group-hover:opacity-100 focus:opacity-100 disabled:opacity-30"
|
|
999
|
+
aria-label={`Close ${target.title || "tab"}`}
|
|
1000
|
+
>
|
|
1001
|
+
<XIcon className="size-3" />
|
|
1002
|
+
</button>
|
|
1003
|
+
</div>
|
|
1004
|
+
))}
|
|
1005
|
+
<button
|
|
1006
|
+
type="button"
|
|
1007
|
+
onClick={props.onOpen}
|
|
1008
|
+
disabled={props.mutating}
|
|
1009
|
+
className="mb-1 grid size-6 shrink-0 place-items-center rounded-og-sm text-og-muted transition hover:bg-og-surface-2 hover:text-og-fg disabled:opacity-40"
|
|
1010
|
+
aria-label="New tab"
|
|
1011
|
+
>
|
|
1012
|
+
<PlusIcon className="size-3.5" />
|
|
1013
|
+
</button>
|
|
1014
|
+
</div>
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function BrowserAddressBar(props: {
|
|
1019
|
+
target: BrowserTarget | null;
|
|
1020
|
+
loading: boolean;
|
|
1021
|
+
onNavigate: (url: string) => void;
|
|
1022
|
+
onReload: () => void;
|
|
1023
|
+
}) {
|
|
1024
|
+
const [draft, setDraft] = useState(props.target?.url ?? "");
|
|
1025
|
+
const focusedRef = useRef(false);
|
|
1026
|
+
useEffect(() => {
|
|
1027
|
+
if (!focusedRef.current) setDraft(props.target?.url ?? "");
|
|
1028
|
+
}, [props.target?.id, props.target?.url]);
|
|
1029
|
+
const submit = (event: FormEvent) => {
|
|
1030
|
+
event.preventDefault();
|
|
1031
|
+
const normalized = normalizeBrowserAddress(draft);
|
|
1032
|
+
if (normalized) props.onNavigate(normalized);
|
|
1033
|
+
};
|
|
1034
|
+
return (
|
|
1035
|
+
<form
|
|
1036
|
+
onSubmit={submit}
|
|
1037
|
+
className="flex h-10 shrink-0 items-center gap-1.5 border-b border-og-border bg-og-bg px-2"
|
|
1038
|
+
>
|
|
1039
|
+
<button
|
|
1040
|
+
type="button"
|
|
1041
|
+
onClick={props.onReload}
|
|
1042
|
+
disabled={!props.target || props.loading}
|
|
1043
|
+
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-35"
|
|
1044
|
+
aria-label="Reload"
|
|
1045
|
+
>
|
|
1046
|
+
<RefreshCwIcon className={cn("size-3.5", props.loading && "animate-spin")} />
|
|
1047
|
+
</button>
|
|
1048
|
+
<div className="flex h-7 min-w-0 flex-1 items-center gap-2 rounded-og-sm border border-og-border bg-og-surface-0 px-2 focus-within:border-og-accent/60">
|
|
1049
|
+
<Globe2Icon className="size-3 shrink-0 text-og-subtle" />
|
|
1050
|
+
<input
|
|
1051
|
+
value={draft}
|
|
1052
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
1053
|
+
onFocus={() => {
|
|
1054
|
+
focusedRef.current = true;
|
|
1055
|
+
}}
|
|
1056
|
+
onBlur={() => {
|
|
1057
|
+
focusedRef.current = false;
|
|
1058
|
+
}}
|
|
1059
|
+
disabled={!props.target}
|
|
1060
|
+
className="min-w-0 flex-1 bg-transparent text-og-control text-og-fg placeholder:text-og-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-accent disabled:opacity-50"
|
|
1061
|
+
aria-label="Address"
|
|
1062
|
+
spellCheck={false}
|
|
1063
|
+
placeholder="Search or enter address"
|
|
1064
|
+
/>
|
|
1065
|
+
</div>
|
|
1066
|
+
</form>
|
|
1067
|
+
);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function BrowserViewport(props: {
|
|
1071
|
+
frame: BrowserFrame | null;
|
|
1072
|
+
connectionState: string;
|
|
1073
|
+
connectionError: Error | null;
|
|
1074
|
+
observation: ReturnType<typeof useBrowserSession>["observation"];
|
|
1075
|
+
mutating: boolean;
|
|
1076
|
+
activityLabel?: string | undefined;
|
|
1077
|
+
onAction: (action: BrowserAction, frame: BrowserFrame | null) => Promise<void>;
|
|
1078
|
+
onReconnect: () => void;
|
|
1079
|
+
onError: (cause: unknown) => void;
|
|
1080
|
+
}) {
|
|
1081
|
+
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
1082
|
+
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
|
1083
|
+
const pointerStartRef = useRef<PointerStart | null>(null);
|
|
1084
|
+
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
1085
|
+
const lastClickRef = useRef<{
|
|
1086
|
+
at: number;
|
|
1087
|
+
x: number;
|
|
1088
|
+
y: number;
|
|
1089
|
+
frame: BrowserFrame;
|
|
1090
|
+
} | null>(null);
|
|
1091
|
+
const wheelRef = useRef<{
|
|
1092
|
+
x: number;
|
|
1093
|
+
y: number;
|
|
1094
|
+
deltaX: number;
|
|
1095
|
+
deltaY: number;
|
|
1096
|
+
frame: BrowserFrame;
|
|
1097
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
1098
|
+
} | null>(null);
|
|
1099
|
+
const actionRef = useRef(props.onAction);
|
|
1100
|
+
const errorRef = useRef(props.onError);
|
|
1101
|
+
const actionTailRef = useRef<Promise<void>>(Promise.resolve());
|
|
1102
|
+
actionRef.current = props.onAction;
|
|
1103
|
+
errorRef.current = props.onError;
|
|
1104
|
+
|
|
1105
|
+
useEffect(() => {
|
|
1106
|
+
const frame = props.frame;
|
|
1107
|
+
const canvas = canvasRef.current;
|
|
1108
|
+
if (!frame || !canvas) return;
|
|
1109
|
+
let cancelled = false;
|
|
1110
|
+
let objectUrl: string | null = null;
|
|
1111
|
+
void (async () => {
|
|
1112
|
+
try {
|
|
1113
|
+
const blob = new Blob([frame.data.slice().buffer], {
|
|
1114
|
+
type: frame.mediaType,
|
|
1115
|
+
});
|
|
1116
|
+
if (typeof createImageBitmap === "function") {
|
|
1117
|
+
const bitmap = await createImageBitmap(blob);
|
|
1118
|
+
if (cancelled) {
|
|
1119
|
+
bitmap.close();
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
paintCanvas(canvas, bitmap, frame.width, frame.height);
|
|
1123
|
+
bitmap.close();
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
objectUrl = URL.createObjectURL(blob);
|
|
1127
|
+
const image = await loadImage(objectUrl);
|
|
1128
|
+
if (!cancelled) paintCanvas(canvas, image, frame.width, frame.height);
|
|
1129
|
+
} catch (cause) {
|
|
1130
|
+
if (!cancelled) errorRef.current(cause);
|
|
1131
|
+
}
|
|
1132
|
+
})();
|
|
1133
|
+
return () => {
|
|
1134
|
+
cancelled = true;
|
|
1135
|
+
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
1136
|
+
};
|
|
1137
|
+
}, [props.frame]);
|
|
1138
|
+
|
|
1139
|
+
useEffect(
|
|
1140
|
+
() => () => {
|
|
1141
|
+
if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
|
|
1142
|
+
if (wheelRef.current?.timer) clearTimeout(wheelRef.current.timer);
|
|
1143
|
+
},
|
|
1144
|
+
[],
|
|
1145
|
+
);
|
|
1146
|
+
|
|
1147
|
+
const enqueue = useCallback((action: BrowserAction, frame: BrowserFrame | null) => {
|
|
1148
|
+
actionTailRef.current = actionTailRef.current
|
|
1149
|
+
.catch(() => undefined)
|
|
1150
|
+
.then(async () => await actionRef.current(action, frame))
|
|
1151
|
+
.catch((cause) => errorRef.current(cause));
|
|
1152
|
+
}, []);
|
|
1153
|
+
|
|
1154
|
+
const point = useCallback(
|
|
1155
|
+
(frame: BrowserFrame, clientX: number, clientY: number) =>
|
|
1156
|
+
browserPoint(canvasRef.current, frame, clientX, clientY),
|
|
1157
|
+
[],
|
|
1158
|
+
);
|
|
1159
|
+
|
|
1160
|
+
const pointerDown = (event: PointerEvent<HTMLCanvasElement>) => {
|
|
1161
|
+
if (!props.frame || props.mutating) return;
|
|
1162
|
+
pointerStartRef.current = {
|
|
1163
|
+
x: event.clientX,
|
|
1164
|
+
y: event.clientY,
|
|
1165
|
+
pointerId: event.pointerId,
|
|
1166
|
+
frame: props.frame,
|
|
1167
|
+
};
|
|
1168
|
+
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
1169
|
+
inputRef.current?.focus({ preventScroll: true });
|
|
1170
|
+
};
|
|
1171
|
+
|
|
1172
|
+
const pointerUp = (event: PointerEvent<HTMLCanvasElement>) => {
|
|
1173
|
+
const start = pointerStartRef.current;
|
|
1174
|
+
pointerStartRef.current = null;
|
|
1175
|
+
if (!start || start.pointerId !== event.pointerId) return;
|
|
1176
|
+
const from = point(start.frame, start.x, start.y);
|
|
1177
|
+
const to = point(start.frame, event.clientX, event.clientY);
|
|
1178
|
+
if (!from || !to) return;
|
|
1179
|
+
if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > 5) {
|
|
1180
|
+
enqueue(
|
|
1181
|
+
{
|
|
1182
|
+
type: "pointer",
|
|
1183
|
+
action: "drag",
|
|
1184
|
+
x: from.x,
|
|
1185
|
+
y: from.y,
|
|
1186
|
+
endX: to.x,
|
|
1187
|
+
endY: to.y,
|
|
1188
|
+
},
|
|
1189
|
+
start.frame,
|
|
1190
|
+
);
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
const now = Date.now();
|
|
1194
|
+
const previous = lastClickRef.current;
|
|
1195
|
+
if (
|
|
1196
|
+
previous &&
|
|
1197
|
+
now - previous.at < 280 &&
|
|
1198
|
+
Math.hypot(previous.x - to.x, previous.y - to.y) < 6 &&
|
|
1199
|
+
sameFrameFence(previous.frame, start.frame)
|
|
1200
|
+
) {
|
|
1201
|
+
if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
|
|
1202
|
+
clickTimerRef.current = null;
|
|
1203
|
+
lastClickRef.current = null;
|
|
1204
|
+
enqueue({ type: "pointer", action: "double_click", x: to.x, y: to.y }, start.frame);
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
lastClickRef.current = { at: now, x: to.x, y: to.y, frame: start.frame };
|
|
1208
|
+
clickTimerRef.current = setTimeout(() => {
|
|
1209
|
+
clickTimerRef.current = null;
|
|
1210
|
+
lastClickRef.current = null;
|
|
1211
|
+
enqueue({ type: "pointer", action: "click", x: to.x, y: to.y }, start.frame);
|
|
1212
|
+
}, 280);
|
|
1213
|
+
};
|
|
1214
|
+
|
|
1215
|
+
const contextMenu = (event: MouseEvent<HTMLCanvasElement>) => {
|
|
1216
|
+
event.preventDefault();
|
|
1217
|
+
const frame = props.frame;
|
|
1218
|
+
if (!frame) return;
|
|
1219
|
+
const at = point(frame, event.clientX, event.clientY);
|
|
1220
|
+
if (at) enqueue({ type: "pointer", action: "click", x: at.x, y: at.y, button: "right" }, frame);
|
|
1221
|
+
};
|
|
1222
|
+
|
|
1223
|
+
const wheel = (event: WheelEvent<HTMLCanvasElement>) => {
|
|
1224
|
+
const frame = props.frame;
|
|
1225
|
+
if (!frame) return;
|
|
1226
|
+
const at = point(frame, event.clientX, event.clientY);
|
|
1227
|
+
if (!at) return;
|
|
1228
|
+
event.preventDefault();
|
|
1229
|
+
const pending = wheelRef.current;
|
|
1230
|
+
if (pending?.timer) clearTimeout(pending.timer);
|
|
1231
|
+
wheelRef.current = {
|
|
1232
|
+
x: at.x,
|
|
1233
|
+
y: at.y,
|
|
1234
|
+
deltaX: (pending && sameFrameFence(pending.frame, frame) ? pending.deltaX : 0) + event.deltaX,
|
|
1235
|
+
deltaY: (pending && sameFrameFence(pending.frame, frame) ? pending.deltaY : 0) + event.deltaY,
|
|
1236
|
+
frame,
|
|
1237
|
+
timer: setTimeout(() => {
|
|
1238
|
+
const batch = wheelRef.current;
|
|
1239
|
+
wheelRef.current = null;
|
|
1240
|
+
if (batch)
|
|
1241
|
+
enqueue(
|
|
1242
|
+
{
|
|
1243
|
+
type: "pointer",
|
|
1244
|
+
action: "scroll",
|
|
1245
|
+
x: batch.x,
|
|
1246
|
+
y: batch.y,
|
|
1247
|
+
deltaX: batch.deltaX,
|
|
1248
|
+
deltaY: batch.deltaY,
|
|
1249
|
+
},
|
|
1250
|
+
batch.frame,
|
|
1251
|
+
);
|
|
1252
|
+
}, 45),
|
|
1253
|
+
};
|
|
1254
|
+
};
|
|
1255
|
+
|
|
1256
|
+
const keyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
1257
|
+
const key = browserKey(event);
|
|
1258
|
+
if (!key) return;
|
|
1259
|
+
event.preventDefault();
|
|
1260
|
+
enqueue({ type: "press", key }, props.frame);
|
|
1261
|
+
};
|
|
1262
|
+
|
|
1263
|
+
const input = (value: string) => {
|
|
1264
|
+
if (!value) return;
|
|
1265
|
+
enqueue({ type: "type", text: value }, props.frame);
|
|
1266
|
+
if (inputRef.current) inputRef.current.value = "";
|
|
1267
|
+
};
|
|
1268
|
+
|
|
1269
|
+
const showCanvas = props.frame !== null;
|
|
1270
|
+
return (
|
|
1271
|
+
<div className="relative min-h-0 flex-1 overflow-hidden bg-black">
|
|
1272
|
+
<canvas
|
|
1273
|
+
ref={canvasRef}
|
|
1274
|
+
className={cn(
|
|
1275
|
+
"absolute inset-0 m-auto max-h-full max-w-full touch-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-og-accent",
|
|
1276
|
+
!showCanvas && "invisible",
|
|
1277
|
+
)}
|
|
1278
|
+
onPointerDown={pointerDown}
|
|
1279
|
+
onPointerUp={pointerUp}
|
|
1280
|
+
onPointerCancel={() => {
|
|
1281
|
+
pointerStartRef.current = null;
|
|
1282
|
+
}}
|
|
1283
|
+
onContextMenu={contextMenu}
|
|
1284
|
+
onWheel={wheel}
|
|
1285
|
+
aria-label="Interactive browser page"
|
|
1286
|
+
/>
|
|
1287
|
+
<textarea
|
|
1288
|
+
ref={inputRef}
|
|
1289
|
+
defaultValue=""
|
|
1290
|
+
onInput={(event) => input(event.currentTarget.value)}
|
|
1291
|
+
onKeyDown={keyDown}
|
|
1292
|
+
className="pointer-events-none absolute left-1/2 top-1/2 size-px resize-none overflow-hidden opacity-0"
|
|
1293
|
+
aria-label="Browser keyboard input"
|
|
1294
|
+
autoCapitalize="off"
|
|
1295
|
+
autoCorrect="off"
|
|
1296
|
+
spellCheck={false}
|
|
1297
|
+
/>
|
|
1298
|
+
{!showCanvas ? (
|
|
1299
|
+
<SemanticBrowserFallback
|
|
1300
|
+
observation={props.observation}
|
|
1301
|
+
connectionState={props.connectionState}
|
|
1302
|
+
error={props.connectionError}
|
|
1303
|
+
onAction={(action) => enqueue(action, null)}
|
|
1304
|
+
onReconnect={props.onReconnect}
|
|
1305
|
+
/>
|
|
1306
|
+
) : null}
|
|
1307
|
+
{props.mutating ? (
|
|
1308
|
+
<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">
|
|
1309
|
+
<LoaderCircleIcon className="size-3 animate-spin" /> {props.activityLabel ?? "Acting"}
|
|
1310
|
+
</div>
|
|
1311
|
+
) : null}
|
|
1312
|
+
</div>
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function SemanticBrowserFallback(props: {
|
|
1317
|
+
observation: ReturnType<typeof useBrowserSession>["observation"];
|
|
1318
|
+
connectionState: string;
|
|
1319
|
+
error: Error | null;
|
|
1320
|
+
onAction: (action: BrowserAction) => void;
|
|
1321
|
+
onReconnect: () => void;
|
|
1322
|
+
}) {
|
|
1323
|
+
const nodes = semanticNodes(
|
|
1324
|
+
props.observation?.semantic?.kind === "snapshot" ? props.observation.semantic.roots : [],
|
|
1325
|
+
);
|
|
1326
|
+
const interactive = nodes.filter((node) => node.actions.includes("click")).slice(0, 8);
|
|
1327
|
+
return (
|
|
1328
|
+
<div className="absolute inset-0 grid place-items-center bg-og-bg p-6">
|
|
1329
|
+
<div className="w-full max-w-md rounded-og-lg border border-og-border bg-og-surface-0 p-4 shadow-lg">
|
|
1330
|
+
<div className="flex items-center gap-2">
|
|
1331
|
+
{props.error ? (
|
|
1332
|
+
<CircleAlertIcon className="size-4 text-og-status-error" />
|
|
1333
|
+
) : (
|
|
1334
|
+
<LoaderCircleIcon className="size-4 animate-spin text-og-muted" />
|
|
1335
|
+
)}
|
|
1336
|
+
<p className="text-og-menu font-medium text-og-fg">
|
|
1337
|
+
{props.error ? "Live view disconnected" : browserConnectionLabel(props.connectionState)}
|
|
1338
|
+
</p>
|
|
1339
|
+
</div>
|
|
1340
|
+
{interactive.length > 0 ? (
|
|
1341
|
+
<div className="mt-3 border-t border-og-border pt-3">
|
|
1342
|
+
<p className="mb-2 text-og-xs text-og-subtle">Page controls remain available</p>
|
|
1343
|
+
<div className="flex flex-wrap gap-1.5">
|
|
1344
|
+
{interactive.map((node) => (
|
|
1345
|
+
<button
|
|
1346
|
+
key={node.ref}
|
|
1347
|
+
type="button"
|
|
1348
|
+
onClick={() =>
|
|
1349
|
+
props.onAction({
|
|
1350
|
+
type: "click",
|
|
1351
|
+
locator: { kind: "ref", ref: node.ref },
|
|
1352
|
+
})
|
|
1353
|
+
}
|
|
1354
|
+
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"
|
|
1355
|
+
>
|
|
1356
|
+
{node.name || node.role}
|
|
1357
|
+
</button>
|
|
1358
|
+
))}
|
|
1359
|
+
</div>
|
|
1360
|
+
</div>
|
|
1361
|
+
) : null}
|
|
1362
|
+
{props.error ? (
|
|
1363
|
+
<button
|
|
1364
|
+
type="button"
|
|
1365
|
+
onClick={props.onReconnect}
|
|
1366
|
+
className="mt-3 text-og-control font-medium text-og-accent hover:underline"
|
|
1367
|
+
>
|
|
1368
|
+
Reconnect
|
|
1369
|
+
</button>
|
|
1370
|
+
) : null}
|
|
1371
|
+
</div>
|
|
1372
|
+
</div>
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
function BrowserStatusBar(props: {
|
|
1377
|
+
session: BrowserSession | null;
|
|
1378
|
+
profile: BrowserIdentity | null;
|
|
1379
|
+
target: BrowserTarget | null;
|
|
1380
|
+
observation: ReturnType<typeof useBrowserSession>["observation"];
|
|
1381
|
+
connectionState: string;
|
|
1382
|
+
refreshing: boolean;
|
|
1383
|
+
onOpenComputer?: (() => void) | undefined;
|
|
1384
|
+
onDiagnostics: () => void;
|
|
1385
|
+
}) {
|
|
1386
|
+
const errors = props.observation?.diagnostics.consoleErrorCount ?? 0;
|
|
1387
|
+
const failed = props.observation?.diagnostics.failedRequestCount ?? 0;
|
|
1388
|
+
return (
|
|
1389
|
+
<div className="flex h-7 shrink-0 items-center gap-2 border-t border-og-border bg-og-surface-0 px-2 text-og-xs text-og-subtle">
|
|
1390
|
+
<span
|
|
1391
|
+
className={cn(
|
|
1392
|
+
"size-1.5 rounded-full",
|
|
1393
|
+
props.connectionState === "live" ? "bg-og-status-running" : "bg-og-muted",
|
|
1394
|
+
)}
|
|
1395
|
+
/>
|
|
1396
|
+
<span>
|
|
1397
|
+
{props.connectionState === "live" ? "Live" : browserConnectionLabel(props.connectionState)}
|
|
1398
|
+
</span>
|
|
1399
|
+
<span>{props.profile?.name ?? "Temporary browser"}</span>
|
|
1400
|
+
<span className="min-w-0 flex-1 truncate">{props.target?.title}</span>
|
|
1401
|
+
{props.refreshing ? <LoaderCircleIcon className="size-3 animate-spin" /> : null}
|
|
1402
|
+
{props.onOpenComputer ? (
|
|
1403
|
+
<button
|
|
1404
|
+
type="button"
|
|
1405
|
+
onClick={props.onOpenComputer}
|
|
1406
|
+
className="flex items-center gap-1 rounded px-1.5 py-0.5 transition hover:bg-og-surface-2 hover:text-og-fg"
|
|
1407
|
+
aria-label="Open this browser window in Computer"
|
|
1408
|
+
>
|
|
1409
|
+
<MonitorIcon className="size-3" />
|
|
1410
|
+
Computer
|
|
1411
|
+
</button>
|
|
1412
|
+
) : null}
|
|
1413
|
+
<button
|
|
1414
|
+
type="button"
|
|
1415
|
+
onClick={props.onDiagnostics}
|
|
1416
|
+
className="flex items-center gap-1 rounded px-1.5 py-0.5 transition hover:bg-og-surface-2 hover:text-og-fg"
|
|
1417
|
+
>
|
|
1418
|
+
<BugIcon className="size-3" />
|
|
1419
|
+
{errors + failed > 0 ? errors + failed : "Debug"}
|
|
1420
|
+
</button>
|
|
1421
|
+
</div>
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
function BrowserNotice(props: { icon: ReactNode; text: string; className?: string }) {
|
|
1426
|
+
return (
|
|
1427
|
+
<div
|
|
1428
|
+
className={cn(
|
|
1429
|
+
"grid h-full place-items-center bg-og-bg text-og-control text-og-muted",
|
|
1430
|
+
props.className,
|
|
1431
|
+
)}
|
|
1432
|
+
>
|
|
1433
|
+
<div className="flex items-center gap-2">
|
|
1434
|
+
{props.icon}
|
|
1435
|
+
{props.text}
|
|
1436
|
+
</div>
|
|
1437
|
+
</div>
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function paintCanvas(
|
|
1442
|
+
canvas: HTMLCanvasElement,
|
|
1443
|
+
image: CanvasImageSource,
|
|
1444
|
+
width: number,
|
|
1445
|
+
height: number,
|
|
1446
|
+
): void {
|
|
1447
|
+
canvas.width = width;
|
|
1448
|
+
canvas.height = height;
|
|
1449
|
+
const context = canvas.getContext("2d", { alpha: false });
|
|
1450
|
+
if (!context) throw new Error("Browser canvas is unavailable.");
|
|
1451
|
+
context.drawImage(image, 0, 0, width, height);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
function loadImage(url: string): Promise<HTMLImageElement> {
|
|
1455
|
+
return new Promise((resolve, reject) => {
|
|
1456
|
+
const image = new Image();
|
|
1457
|
+
image.onload = () => resolve(image);
|
|
1458
|
+
image.onerror = () => reject(new Error("Browser frame image could not be decoded."));
|
|
1459
|
+
image.src = url;
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
function browserPoint(
|
|
1464
|
+
canvas: HTMLCanvasElement | null,
|
|
1465
|
+
frame: BrowserFrame | null,
|
|
1466
|
+
clientX: number,
|
|
1467
|
+
clientY: number,
|
|
1468
|
+
): { x: number; y: number } | null {
|
|
1469
|
+
if (!canvas || !frame) return null;
|
|
1470
|
+
const rect = canvas.getBoundingClientRect();
|
|
1471
|
+
if (rect.width <= 0 || rect.height <= 0) return null;
|
|
1472
|
+
const pixelX = ((clientX - rect.left) / rect.width) * frame.width;
|
|
1473
|
+
const pixelY = ((clientY - rect.top) / rect.height) * frame.height;
|
|
1474
|
+
return {
|
|
1475
|
+
x: Math.max(0, pixelX / frame.deviceScaleFactor),
|
|
1476
|
+
y: Math.max(0, pixelY / frame.deviceScaleFactor),
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
function browserKey(event: KeyboardEvent<HTMLTextAreaElement>): string | null {
|
|
1481
|
+
const special = new Set([
|
|
1482
|
+
"Enter",
|
|
1483
|
+
"Tab",
|
|
1484
|
+
"Escape",
|
|
1485
|
+
"Backspace",
|
|
1486
|
+
"Delete",
|
|
1487
|
+
"ArrowUp",
|
|
1488
|
+
"ArrowDown",
|
|
1489
|
+
"ArrowLeft",
|
|
1490
|
+
"ArrowRight",
|
|
1491
|
+
]);
|
|
1492
|
+
const modified = event.altKey || event.ctrlKey || event.metaKey;
|
|
1493
|
+
if (!modified && !special.has(event.key)) return null;
|
|
1494
|
+
const parts: string[] = [];
|
|
1495
|
+
if (event.ctrlKey) parts.push("Control");
|
|
1496
|
+
if (event.altKey) parts.push("Alt");
|
|
1497
|
+
if (event.metaKey) parts.push("Meta");
|
|
1498
|
+
if (event.shiftKey) parts.push("Shift");
|
|
1499
|
+
parts.push(event.key === " " ? "Space" : event.key);
|
|
1500
|
+
return parts.join("+");
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
function normalizeBrowserAddress(value: string): string | null {
|
|
1504
|
+
const trimmed = value.trim();
|
|
1505
|
+
if (!trimmed) return null;
|
|
1506
|
+
const candidate = /^[a-z][a-z0-9+.-]*:/iu.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
1507
|
+
try {
|
|
1508
|
+
const url = new URL(candidate);
|
|
1509
|
+
return ["http:", "https:", "about:"].includes(url.protocol) ? url.href : null;
|
|
1510
|
+
} catch {
|
|
1511
|
+
return null;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
function semanticNodes(roots: readonly InteractionSemanticNode[]): InteractionSemanticNode[] {
|
|
1516
|
+
const result: InteractionSemanticNode[] = [];
|
|
1517
|
+
const stack = [...roots].reverse();
|
|
1518
|
+
while (stack.length > 0) {
|
|
1519
|
+
const node = stack.pop()!;
|
|
1520
|
+
result.push(node);
|
|
1521
|
+
if (node.children) stack.push(...[...node.children].reverse());
|
|
1522
|
+
}
|
|
1523
|
+
return result;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
function frameMatchesObservation(
|
|
1527
|
+
frame: BrowserFrame | null,
|
|
1528
|
+
observation: BrowserObservation | null,
|
|
1529
|
+
): frame is BrowserFrame {
|
|
1530
|
+
return Boolean(
|
|
1531
|
+
frame &&
|
|
1532
|
+
observation &&
|
|
1533
|
+
frame.browserSessionId === observation.browserSessionId &&
|
|
1534
|
+
frame.controllerGeneration === observation.target.controllerGeneration &&
|
|
1535
|
+
frame.targetId === observation.target.id &&
|
|
1536
|
+
frame.targetGeneration === observation.target.targetGeneration &&
|
|
1537
|
+
frame.documentGeneration === observation.target.documentGeneration &&
|
|
1538
|
+
frame.frameId === observation.frameId,
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
function sameFrameFence(left: BrowserFrame, right: BrowserFrame): boolean {
|
|
1543
|
+
return (
|
|
1544
|
+
left.browserSessionId === right.browserSessionId &&
|
|
1545
|
+
left.controllerGeneration === right.controllerGeneration &&
|
|
1546
|
+
left.targetId === right.targetId &&
|
|
1547
|
+
left.targetGeneration === right.targetGeneration &&
|
|
1548
|
+
left.documentGeneration === right.documentGeneration &&
|
|
1549
|
+
left.frameId === right.frameId
|
|
1550
|
+
);
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
function isLiveBrowser(session: BrowserSession): boolean {
|
|
1554
|
+
return !["ending", "ended", "failed", "lost"].includes(session.lifecycle);
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
function placementLabel(session: BrowserSession | undefined): string {
|
|
1558
|
+
if (!session) return "";
|
|
1559
|
+
switch (session.placement.kind) {
|
|
1560
|
+
case "sandbox_group":
|
|
1561
|
+
return "Agent sandbox";
|
|
1562
|
+
case "connected_machine":
|
|
1563
|
+
return "Connected machine";
|
|
1564
|
+
case "attached_device":
|
|
1565
|
+
return "Your browser";
|
|
1566
|
+
case "external_provider":
|
|
1567
|
+
return "Cloud browser";
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
function browserConnectionLabel(state: string): string {
|
|
1572
|
+
switch (state) {
|
|
1573
|
+
case "attaching":
|
|
1574
|
+
return "Opening browser…";
|
|
1575
|
+
case "connecting":
|
|
1576
|
+
return "Connecting…";
|
|
1577
|
+
case "reconnecting":
|
|
1578
|
+
return "Reconnecting…";
|
|
1579
|
+
case "error":
|
|
1580
|
+
return "Disconnected";
|
|
1581
|
+
default:
|
|
1582
|
+
return "Waiting for browser…";
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
function shortUrl(value: string): string {
|
|
1587
|
+
try {
|
|
1588
|
+
return new URL(value).hostname || value;
|
|
1589
|
+
} catch {
|
|
1590
|
+
return value;
|
|
1591
|
+
}
|
|
1592
|
+
}
|