@opengeni/react 0.52.1 → 1.0.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 +51 -1
- package/dist/artifacts.d.ts +1 -1
- package/dist/artifacts.js +43 -14
- package/dist/artifacts.js.map +1 -1
- package/dist/{chunk-ROIRTM37.js → chunk-5UQPNLIO.js} +6 -6
- package/dist/{chunk-JR3QFY6B.js → chunk-6KLX2RH5.js} +49 -2
- package/dist/chunk-6KLX2RH5.js.map +1 -0
- package/dist/{chunk-FWS3KC3Z.js → chunk-B6WPZSTT.js} +104 -104
- package/dist/{chunk-FWS3KC3Z.js.map → chunk-B6WPZSTT.js.map} +1 -1
- package/dist/{chunk-HDBG7XGO.js → chunk-C5ZPSSS5.js} +50 -1
- package/dist/chunk-C5ZPSSS5.js.map +1 -0
- package/dist/{chunk-X4S56Y2H.js → chunk-HA46CYQK.js} +135 -11
- package/dist/chunk-HA46CYQK.js.map +1 -0
- package/dist/{chunk-724BW4TW.js → chunk-IGC6A2ZI.js} +2 -2
- package/dist/chunk-LLOLUHVG.js +5937 -0
- package/dist/chunk-LLOLUHVG.js.map +1 -0
- package/dist/{chunk-WYA65Y3F.js → chunk-MKPIAGVL.js} +13 -3
- package/dist/chunk-MKPIAGVL.js.map +1 -0
- package/dist/chunk-R3AZOAB5.js +933 -0
- package/dist/chunk-R3AZOAB5.js.map +1 -0
- package/dist/{chunk-23EJ676W.js → chunk-XBZWOM2K.js} +9 -4
- package/dist/chunk-XBZWOM2K.js.map +1 -0
- package/dist/{chunk-JAGDUCZQ.js → chunk-Y7OEDUJ5.js} +17 -13
- package/dist/chunk-Y7OEDUJ5.js.map +1 -0
- package/dist/client.d.ts +6 -4
- package/dist/components/artifacts/index.d.ts +1 -0
- package/dist/components/artifacts/published-html-artifact-frame.d.ts +14 -0
- package/dist/components/browser-input.d.ts +7 -0
- package/dist/components/browser-viewer.d.ts +5 -4
- package/dist/components/computer-viewer.d.ts +2 -1
- package/dist/components/desktop-viewer.d.ts +26 -2
- package/dist/components/interaction-intervention-banner.d.ts +11 -0
- package/dist/components/sandbox-terminal.d.ts +4 -1
- package/dist/components/sandbox-workspace.d.ts +3 -0
- package/dist/composer.js +6 -6
- package/dist/hooks/use-auth-runs.d.ts +32 -0
- package/dist/hooks/use-browser-downloads.d.ts +25 -0
- package/dist/hooks/use-browser-identities.d.ts +5 -1
- package/dist/hooks/use-browser-session.d.ts +2 -1
- package/dist/hooks/use-browser-sessions.d.ts +1 -1
- package/dist/hooks/use-channels.d.ts +26 -0
- package/dist/hooks/use-computer-frame-stream.d.ts +1 -1
- package/dist/hooks/use-computer-session.d.ts +2 -1
- package/dist/hooks/use-computer-sessions.d.ts +1 -1
- package/dist/hooks/use-desktop-stream.d.ts +4 -0
- package/dist/hooks/use-interaction-interventions.d.ts +27 -0
- package/dist/hooks/use-interaction-invalidation.d.ts +19 -0
- package/dist/hooks/use-network-routes.d.ts +25 -0
- package/dist/hooks/use-site-auth-connections.d.ts +25 -0
- package/dist/hooks/use-supergrok-accounts.d.ts +26 -0
- package/dist/hooks/use-terminal-stream.d.ts +3 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1220 -1545
- package/dist/index.js.map +1 -1
- package/dist/interaction.d.ts +13 -3
- package/dist/interaction.js +520 -6
- package/dist/interaction.js.map +1 -1
- package/dist/interactive-workbench-viewers-TOHBXJEE.js +15 -0
- package/dist/lib/host-platform.d.ts +5 -0
- package/dist/lib/relay-wire.d.ts +20 -1
- package/dist/machines.js +3 -3
- package/dist/model-policy.d.ts +1 -1
- package/dist/model-policy.js +1 -1
- package/dist/provider.d.ts +3 -2
- package/dist/realtime/realtime-control.d.ts +6 -2
- package/dist/realtime.js +138 -24
- package/dist/realtime.js.map +1 -1
- package/dist/session-context.d.ts +10 -2
- package/dist/session-ui.js +3 -3
- package/dist/session.js +11 -11
- package/dist/timeline/types.d.ts +5 -0
- package/package.json +2 -2
- package/src/artifacts.ts +3 -0
- package/src/client.ts +73 -3
- package/src/components/artifacts/index.ts +5 -0
- package/src/components/artifacts/published-html-artifact-frame.tsx +38 -0
- package/src/components/browser-input.ts +65 -0
- package/src/components/browser-viewer.tsx +1287 -177
- package/src/components/computer-viewer.tsx +611 -114
- package/src/components/desktop-viewer.tsx +114 -1
- package/src/components/interaction-intervention-banner.tsx +99 -0
- package/src/components/machines/machine-detail.tsx +1 -1
- package/src/components/model-policy-picker.tsx +15 -0
- package/src/components/sandbox-terminal.tsx +24 -7
- package/src/components/sandbox-workspace.tsx +42 -4
- package/src/components/workspace-dock.tsx +59 -5
- package/src/hooks/use-attached-browsers.ts +13 -11
- package/src/hooks/use-auth-runs.ts +278 -0
- package/src/hooks/use-browser-downloads.ts +218 -0
- package/src/hooks/use-browser-frame-stream.ts +168 -59
- package/src/hooks/use-browser-identities.ts +47 -6
- package/src/hooks/use-browser-session.ts +94 -27
- package/src/hooks/use-browser-sessions.ts +19 -14
- package/src/hooks/use-channels.ts +99 -0
- package/src/hooks/use-computer-frame-stream.ts +184 -61
- package/src/hooks/use-computer-session.ts +22 -2
- package/src/hooks/use-computer-sessions.ts +19 -13
- package/src/hooks/use-desktop-stream.ts +39 -7
- package/src/hooks/use-interaction-interventions.ts +242 -0
- package/src/hooks/use-interaction-invalidation.ts +66 -0
- package/src/hooks/use-machine-chip.ts +13 -3
- package/src/hooks/use-network-routes.ts +214 -0
- package/src/hooks/use-site-auth-connections.ts +226 -0
- package/src/hooks/use-supergrok-accounts.ts +81 -0
- package/src/hooks/use-terminal-stream.ts +288 -55
- package/src/index.ts +8 -0
- package/src/interaction.ts +30 -0
- package/src/lib/host-platform.ts +10 -0
- package/src/lib/relay-wire.ts +43 -4
- package/src/lib/terminal-capability.ts +1 -1
- package/src/model-policy.ts +19 -4
- package/src/provider.tsx +133 -1
- package/src/realtime/realtime-control.tsx +181 -26
- package/src/session-context.ts +66 -1
- package/src/timeline/activity-rail.tsx +6 -0
- package/src/timeline/projection.ts +58 -0
- package/src/timeline/types.ts +2 -0
- package/styles/compiled.css +64 -6
- package/dist/chunk-23EJ676W.js.map +0 -1
- package/dist/chunk-6BBWWOOU.js +0 -82
- package/dist/chunk-6BBWWOOU.js.map +0 -1
- package/dist/chunk-7I2KB3OK.js +0 -3895
- package/dist/chunk-7I2KB3OK.js.map +0 -1
- package/dist/chunk-HDBG7XGO.js.map +0 -1
- package/dist/chunk-JAGDUCZQ.js.map +0 -1
- package/dist/chunk-JR3QFY6B.js.map +0 -1
- package/dist/chunk-WYA65Y3F.js.map +0 -1
- package/dist/chunk-X4S56Y2H.js.map +0 -1
- package/dist/interactive-workbench-viewers-KHNOHWQE.js +0 -13
- /package/dist/{chunk-ROIRTM37.js.map → chunk-5UQPNLIO.js.map} +0 -0
- /package/dist/{chunk-724BW4TW.js.map → chunk-IGC6A2ZI.js.map} +0 -0
- /package/dist/{interactive-workbench-viewers-KHNOHWQE.js.map → interactive-workbench-viewers-TOHBXJEE.js.map} +0 -0
|
@@ -1,29 +1,44 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
AttachedBrowserDevice,
|
|
3
3
|
BrowserAction,
|
|
4
|
+
BrowserActionReceipt,
|
|
5
|
+
BrowserDiagnosticBatch,
|
|
6
|
+
BrowserDownload,
|
|
7
|
+
BrowserDownloadSaveResponse,
|
|
4
8
|
BrowserFrame,
|
|
5
9
|
BrowserIdentity,
|
|
6
10
|
BrowserObservation,
|
|
11
|
+
BrowserRevision,
|
|
7
12
|
BrowserSession,
|
|
8
13
|
BrowserTarget,
|
|
9
14
|
ComputerSession,
|
|
15
|
+
InteractionPlacement,
|
|
16
|
+
InteractionIntervention,
|
|
10
17
|
InteractionSemanticNode,
|
|
11
18
|
} from "@opengeni/sdk/interaction";
|
|
12
19
|
import {
|
|
13
20
|
BugIcon,
|
|
21
|
+
ArchiveIcon,
|
|
22
|
+
CheckIcon,
|
|
14
23
|
ChevronDownIcon,
|
|
15
24
|
CircleAlertIcon,
|
|
25
|
+
DownloadIcon,
|
|
26
|
+
FileCheck2Icon,
|
|
16
27
|
Globe2Icon,
|
|
17
28
|
LoaderCircleIcon,
|
|
18
29
|
MonitorIcon,
|
|
19
30
|
PlusIcon,
|
|
20
31
|
RefreshCwIcon,
|
|
32
|
+
RotateCcwIcon,
|
|
21
33
|
SaveIcon,
|
|
22
34
|
UserRoundIcon,
|
|
23
35
|
XIcon,
|
|
36
|
+
ZapIcon,
|
|
24
37
|
} from "lucide-react";
|
|
25
38
|
import {
|
|
26
39
|
type FormEvent,
|
|
40
|
+
type ClipboardEvent,
|
|
41
|
+
type CompositionEvent,
|
|
27
42
|
type KeyboardEvent,
|
|
28
43
|
type MouseEvent,
|
|
29
44
|
type PointerEvent,
|
|
@@ -42,10 +57,16 @@ import {
|
|
|
42
57
|
} from "../hooks/use-browser-frame-stream";
|
|
43
58
|
import { useAttachedBrowsers } from "../hooks/use-attached-browsers";
|
|
44
59
|
import { useBrowserIdentities } from "../hooks/use-browser-identities";
|
|
60
|
+
import { useBrowserDownloads } from "../hooks/use-browser-downloads";
|
|
45
61
|
import { useBrowserSession } from "../hooks/use-browser-session";
|
|
46
62
|
import { useBrowserSessions } from "../hooks/use-browser-sessions";
|
|
63
|
+
import { useInteractionInterventions } from "../hooks/use-interaction-interventions";
|
|
47
64
|
import { cn } from "../lib/cn";
|
|
65
|
+
import { copyTextToClipboard } from "../lib/clipboard";
|
|
66
|
+
import { formatBytes } from "../lib/format";
|
|
48
67
|
import type { EmbeddedBrowserInteractionClientOverride } from "../session-context";
|
|
68
|
+
import { browserKey, HUMAN_BROWSER_HOME_URL, normalizeBrowserAddress } from "./browser-input";
|
|
69
|
+
import { InteractionInterventionBanner } from "./interaction-intervention-banner";
|
|
49
70
|
|
|
50
71
|
export type BrowserViewerNotification = {
|
|
51
72
|
kind: "error" | "info";
|
|
@@ -61,10 +82,14 @@ export type BrowserViewerProps = EmbeddedBrowserInteractionClientOverride & {
|
|
|
61
82
|
/** Tests/demos only. Production uses the browser's native WebSocket. */
|
|
62
83
|
webSocketFactory?: BrowserFrameWebSocketFactory | undefined;
|
|
63
84
|
renderEmpty?: ((create: () => void, creating: boolean) => ReactNode) | undefined;
|
|
64
|
-
/** Optional host capability for a headed
|
|
65
|
-
*
|
|
85
|
+
/** Optional host capability for placing a human-created headed browser inside
|
|
86
|
+
* an exact ComputerSession. Browser-only embedders still create a headed
|
|
87
|
+
* browser; they simply omit the linked desktop resource. */
|
|
66
88
|
createLinkedComputer?:
|
|
67
|
-
| ((
|
|
89
|
+
| ((
|
|
90
|
+
name: string,
|
|
91
|
+
placement?: InteractionPlacement,
|
|
92
|
+
) => Promise<Pick<ComputerSession, "id" | "placement">>)
|
|
68
93
|
| undefined;
|
|
69
94
|
/** Navigate to the exact linked ComputerSession; never a lookalike desktop. */
|
|
70
95
|
onOpenComputer?: ((computerSessionId: string) => void) | undefined;
|
|
@@ -87,6 +112,13 @@ type BrowserResumeAttempt = {
|
|
|
87
112
|
terminal: boolean;
|
|
88
113
|
error: Error | null;
|
|
89
114
|
};
|
|
115
|
+
type BrowserDiagnosticsView = {
|
|
116
|
+
browserSessionId: string;
|
|
117
|
+
targetId: string;
|
|
118
|
+
loading: boolean;
|
|
119
|
+
batch: BrowserDiagnosticBatch | null;
|
|
120
|
+
error: Error | null;
|
|
121
|
+
};
|
|
90
122
|
|
|
91
123
|
/**
|
|
92
124
|
* Complete browser-native session surface: workspace discovery, agent relevance,
|
|
@@ -106,7 +138,11 @@ export function BrowserViewer({
|
|
|
106
138
|
}: BrowserViewerProps) {
|
|
107
139
|
const registry = useBrowserSessions({ ...override, sessionId, enabled });
|
|
108
140
|
const attached = useAttachedBrowsers({ ...override, enabled });
|
|
109
|
-
const profiles = useBrowserIdentities({ ...override, enabled });
|
|
141
|
+
const profiles = useBrowserIdentities({ ...override, enabled, includeArchived: true });
|
|
142
|
+
const activeProfiles = useMemo(
|
|
143
|
+
() => profiles.identities.filter((identity) => identity.status === "active"),
|
|
144
|
+
[profiles.identities],
|
|
145
|
+
);
|
|
110
146
|
const createRegistryBrowser = registry.create;
|
|
111
147
|
const loadProfileRevisions = profiles.revisions;
|
|
112
148
|
const liveSessions = useMemo(
|
|
@@ -118,15 +154,28 @@ export function BrowserViewer({
|
|
|
118
154
|
[registry.relevantSessions],
|
|
119
155
|
);
|
|
120
156
|
const [selection, setSelection] = useState<BrowserSelection>(null);
|
|
157
|
+
const interventions = useInteractionInterventions({
|
|
158
|
+
...override,
|
|
159
|
+
enabled,
|
|
160
|
+
resourceKind: "browser_session",
|
|
161
|
+
});
|
|
121
162
|
const [creating, setCreating] = useState(false);
|
|
122
163
|
const [savingProfile, setSavingProfile] = useState(false);
|
|
123
164
|
const [baseRevisionOrdinal, setBaseRevisionOrdinal] = useState<number | null>(null);
|
|
165
|
+
const [profileHistory, setProfileHistory] = useState<{
|
|
166
|
+
identityId: string;
|
|
167
|
+
loading: boolean;
|
|
168
|
+
revisions: BrowserRevision[];
|
|
169
|
+
} | null>(null);
|
|
124
170
|
const [resumeAttempt, setResumeAttempt] = useState<{
|
|
125
171
|
sessionId: string;
|
|
126
172
|
attempt: BrowserResumeAttempt;
|
|
127
173
|
} | null>(null);
|
|
128
174
|
const resumeAttemptsRef = useRef(new Map<string, BrowserResumeAttempt>());
|
|
129
175
|
const previousSessionIdRef = useRef(sessionId);
|
|
176
|
+
const seenInterventionIdsRef = useRef(new Set<string>());
|
|
177
|
+
const diagnosticRequestRef = useRef(0);
|
|
178
|
+
const [diagnosticsView, setDiagnosticsView] = useState<BrowserDiagnosticsView | null>(null);
|
|
130
179
|
|
|
131
180
|
const notifyError = useCallback(
|
|
132
181
|
(cause: unknown, fallback: string) => {
|
|
@@ -145,7 +194,11 @@ export function BrowserViewer({
|
|
|
145
194
|
|
|
146
195
|
useEffect(() => {
|
|
147
196
|
const selectedStillLive = liveSessions.some((session) => session.id === selection?.sessionId);
|
|
148
|
-
|
|
197
|
+
// A manually selected workspace browser stays selected, but an unrelated
|
|
198
|
+
// browser must never become the implicit browser for this agent. That made
|
|
199
|
+
// a Modal session appear to own a connected Mac browser.
|
|
200
|
+
if (selection?.pinned && selectedStillLive) return;
|
|
201
|
+
const preferred = relevant[0] ?? null;
|
|
149
202
|
if (!preferred) {
|
|
150
203
|
if (selection) setSelection(null);
|
|
151
204
|
return;
|
|
@@ -161,6 +214,17 @@ export function BrowserViewer({
|
|
|
161
214
|
() => liveSessions.find((session) => session.id === selection?.sessionId) ?? null,
|
|
162
215
|
[liveSessions, selection?.sessionId],
|
|
163
216
|
);
|
|
217
|
+
const interventionCounts = useMemo(
|
|
218
|
+
() => countInterventions(interventions.interventions),
|
|
219
|
+
[interventions.interventions],
|
|
220
|
+
);
|
|
221
|
+
const selectedInterventions = useMemo(
|
|
222
|
+
() =>
|
|
223
|
+
interventions.interventions.filter(
|
|
224
|
+
(intervention) => intervention.resourceId === selection?.sessionId,
|
|
225
|
+
),
|
|
226
|
+
[interventions.interventions, selection?.sessionId],
|
|
227
|
+
);
|
|
164
228
|
const controllerReady = selectedRegistrySession?.lifecycle === "active";
|
|
165
229
|
const resumeSession = registry.resume;
|
|
166
230
|
|
|
@@ -225,19 +289,72 @@ export function BrowserViewer({
|
|
|
225
289
|
browserSessionId: selection?.sessionId ?? null,
|
|
226
290
|
enabled: enabled && selection !== null && controllerReady,
|
|
227
291
|
});
|
|
292
|
+
const downloads = useBrowserDownloads({
|
|
293
|
+
...override,
|
|
294
|
+
browserSessionId: selection?.sessionId ?? null,
|
|
295
|
+
enabled:
|
|
296
|
+
enabled &&
|
|
297
|
+
diagnosticsView !== null &&
|
|
298
|
+
controllerReady &&
|
|
299
|
+
browser.session?.capabilities.downloads === true,
|
|
300
|
+
});
|
|
301
|
+
const loadBrowserDiagnostics = browser.diagnostics;
|
|
302
|
+
|
|
303
|
+
useEffect(() => {
|
|
304
|
+
for (const intervention of interventions.interventions) {
|
|
305
|
+
if (seenInterventionIdsRef.current.has(intervention.id)) continue;
|
|
306
|
+
seenInterventionIdsRef.current.add(intervention.id);
|
|
307
|
+
onNotify?.({
|
|
308
|
+
kind: "info",
|
|
309
|
+
message: `${interventionTitle(intervention)}: ${intervention.reason}`,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}, [interventions.interventions, onNotify]);
|
|
313
|
+
|
|
314
|
+
const resolveIntervention = useCallback(
|
|
315
|
+
(intervention: InteractionIntervention, outcome: "completed" | "dismissed") => {
|
|
316
|
+
void interventions
|
|
317
|
+
.resolve(intervention.id, {
|
|
318
|
+
expectedVersion: intervention.version,
|
|
319
|
+
outcome,
|
|
320
|
+
})
|
|
321
|
+
.then(() => {
|
|
322
|
+
onNotify?.({
|
|
323
|
+
kind: "info",
|
|
324
|
+
message:
|
|
325
|
+
outcome === "completed" ? "Agent notified. Continuing work." : "Request cancelled.",
|
|
326
|
+
});
|
|
327
|
+
})
|
|
328
|
+
.catch((cause) => notifyError(cause, "Could not update the browser request."));
|
|
329
|
+
},
|
|
330
|
+
[interventions, notifyError, onNotify],
|
|
331
|
+
);
|
|
228
332
|
const frames = useBrowserFrameStream({
|
|
229
333
|
...override,
|
|
230
334
|
browserSessionId: selection?.sessionId ?? null,
|
|
231
335
|
targetId: browser.selectedTarget?.id ?? null,
|
|
232
|
-
enabled:
|
|
336
|
+
enabled:
|
|
337
|
+
enabled &&
|
|
338
|
+
selection !== null &&
|
|
339
|
+
controllerReady &&
|
|
340
|
+
browser.selectedTarget !== null &&
|
|
341
|
+
browser.session?.capabilities.liveFrames === true,
|
|
233
342
|
stream: { format: "jpeg", quality: 76, maxWidth: 1_920, maxHeight: 1_200 },
|
|
234
343
|
...(webSocketFactory ? { webSocketFactory } : {}),
|
|
235
344
|
});
|
|
236
|
-
const displayedFrame =
|
|
345
|
+
const displayedFrame = frameMatchesSelectedTarget(
|
|
346
|
+
frames.frame,
|
|
347
|
+
browser.session,
|
|
348
|
+
browser.selectedTarget,
|
|
349
|
+
)
|
|
237
350
|
? frames.frame
|
|
238
351
|
: null;
|
|
239
|
-
const
|
|
240
|
-
|
|
352
|
+
const supportsLiveFrames = browser.session?.capabilities.liveFrames === true;
|
|
353
|
+
const displayConnectionState = supportsLiveFrames
|
|
354
|
+
? frames.state === "live" && !displayedFrame
|
|
355
|
+
? "connecting"
|
|
356
|
+
: frames.state
|
|
357
|
+
: "semantic";
|
|
241
358
|
const selectedProfile = useMemo(
|
|
242
359
|
() =>
|
|
243
360
|
profiles.identities.find(
|
|
@@ -247,24 +364,95 @@ export function BrowserViewer({
|
|
|
247
364
|
[browser.session?.identityId, profiles.identities, selectedRegistrySession?.identityId],
|
|
248
365
|
);
|
|
249
366
|
|
|
367
|
+
useEffect(() => {
|
|
368
|
+
diagnosticRequestRef.current += 1;
|
|
369
|
+
setDiagnosticsView(null);
|
|
370
|
+
}, [browser.session?.id, browser.selectedTarget?.id]);
|
|
371
|
+
|
|
372
|
+
const loadDiagnostics = useCallback(() => {
|
|
373
|
+
const browserSessionId = browser.session?.id;
|
|
374
|
+
const targetId = browser.selectedTarget?.id;
|
|
375
|
+
if (!browserSessionId || !targetId) return;
|
|
376
|
+
const requestId = diagnosticRequestRef.current + 1;
|
|
377
|
+
diagnosticRequestRef.current = requestId;
|
|
378
|
+
setDiagnosticsView((current) => ({
|
|
379
|
+
browserSessionId,
|
|
380
|
+
targetId,
|
|
381
|
+
loading: true,
|
|
382
|
+
batch:
|
|
383
|
+
current?.browserSessionId === browserSessionId && current.targetId === targetId
|
|
384
|
+
? current.batch
|
|
385
|
+
: null,
|
|
386
|
+
error: null,
|
|
387
|
+
}));
|
|
388
|
+
void loadBrowserDiagnostics({ limit: 100 })
|
|
389
|
+
.then((batch) => {
|
|
390
|
+
if (diagnosticRequestRef.current !== requestId) return;
|
|
391
|
+
setDiagnosticsView({ browserSessionId, targetId, loading: false, batch, error: null });
|
|
392
|
+
})
|
|
393
|
+
.catch((cause) => {
|
|
394
|
+
if (diagnosticRequestRef.current !== requestId) return;
|
|
395
|
+
setDiagnosticsView({
|
|
396
|
+
browserSessionId,
|
|
397
|
+
targetId,
|
|
398
|
+
loading: false,
|
|
399
|
+
batch: null,
|
|
400
|
+
error: cause instanceof Error ? cause : new Error(String(cause)),
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
}, [browser.selectedTarget?.id, browser.session?.id, loadBrowserDiagnostics]);
|
|
404
|
+
|
|
405
|
+
const closeDiagnostics = useCallback(() => {
|
|
406
|
+
diagnosticRequestRef.current += 1;
|
|
407
|
+
setDiagnosticsView(null);
|
|
408
|
+
}, []);
|
|
409
|
+
|
|
410
|
+
const refreshDiagnostics = useCallback(() => {
|
|
411
|
+
loadDiagnostics();
|
|
412
|
+
if (browser.session?.capabilities.downloads) void downloads.refresh();
|
|
413
|
+
}, [browser.session?.capabilities.downloads, downloads, loadDiagnostics]);
|
|
414
|
+
|
|
415
|
+
const saveDownload = useCallback(
|
|
416
|
+
async (
|
|
417
|
+
downloadId: string,
|
|
418
|
+
destinationPath: string,
|
|
419
|
+
overwrite: boolean,
|
|
420
|
+
): Promise<BrowserDownloadSaveResponse> => {
|
|
421
|
+
const response = await downloads.saveToWorkspace(downloadId, destinationPath, { overwrite });
|
|
422
|
+
onNotify?.({
|
|
423
|
+
kind: "info",
|
|
424
|
+
message: `${response.download.filename} saved to ${response.destinationPath}.`,
|
|
425
|
+
});
|
|
426
|
+
return response;
|
|
427
|
+
},
|
|
428
|
+
[downloads, onNotify],
|
|
429
|
+
);
|
|
430
|
+
|
|
250
431
|
useEffect(() => {
|
|
251
432
|
const identityId = browser.session?.identityId ?? selectedRegistrySession?.identityId;
|
|
252
433
|
const revisionId = browser.session?.baseRevisionId ?? selectedRegistrySession?.baseRevisionId;
|
|
253
|
-
if (!identityId
|
|
434
|
+
if (!identityId) {
|
|
254
435
|
setBaseRevisionOrdinal(null);
|
|
436
|
+
setProfileHistory(null);
|
|
255
437
|
return;
|
|
256
438
|
}
|
|
257
439
|
let disposed = false;
|
|
258
440
|
setBaseRevisionOrdinal(null);
|
|
441
|
+
setProfileHistory({ identityId, loading: true, revisions: [] });
|
|
259
442
|
void loadProfileRevisions(identityId)
|
|
260
443
|
.then((response) => {
|
|
261
444
|
if (!disposed) {
|
|
262
445
|
setBaseRevisionOrdinal(
|
|
263
|
-
|
|
446
|
+
revisionId
|
|
447
|
+
? (response.revisions.find((revision) => revision.id === revisionId)?.ordinal ?? null)
|
|
448
|
+
: null,
|
|
264
449
|
);
|
|
450
|
+
setProfileHistory({ identityId, loading: false, revisions: response.revisions });
|
|
265
451
|
}
|
|
266
452
|
})
|
|
267
|
-
.catch(() =>
|
|
453
|
+
.catch(() => {
|
|
454
|
+
if (!disposed) setProfileHistory({ identityId, loading: false, revisions: [] });
|
|
455
|
+
});
|
|
268
456
|
return () => {
|
|
269
457
|
disposed = true;
|
|
270
458
|
};
|
|
@@ -281,23 +469,39 @@ export function BrowserViewer({
|
|
|
281
469
|
if (creating) return;
|
|
282
470
|
const identity =
|
|
283
471
|
choice.kind === "profile"
|
|
284
|
-
?
|
|
472
|
+
? activeProfiles.find((candidate) => candidate.id === choice.identityId)
|
|
285
473
|
: null;
|
|
286
474
|
const device = choice.kind === "attached" ? choice.device : null;
|
|
287
475
|
const browserName =
|
|
288
476
|
device?.profileLabel ?? device?.name ?? (identity ? `${identity.name} browser` : "Browser");
|
|
289
477
|
setCreating(true);
|
|
290
478
|
void (async () => {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
479
|
+
let linkedComputer: Pick<ComputerSession, "id" | "placement"> | null = null;
|
|
480
|
+
let computerViewUnavailable = false;
|
|
481
|
+
if (createLinkedComputer) {
|
|
482
|
+
try {
|
|
483
|
+
linkedComputer = await createLinkedComputer(
|
|
484
|
+
`${browserName} computer`,
|
|
485
|
+
device ? { kind: "attached_device", deviceId: device.id } : undefined,
|
|
486
|
+
);
|
|
487
|
+
} catch {
|
|
488
|
+
// Browser control is independently useful on locked/headless placements.
|
|
489
|
+
// A linked ComputerSession is an optional visual enhancement and must not
|
|
490
|
+
// prevent the canonical BrowserSession from starting.
|
|
491
|
+
computerViewUnavailable = true;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
295
494
|
const response = await createRegistryBrowser({
|
|
296
495
|
sessionId,
|
|
297
496
|
name: browserName,
|
|
497
|
+
// Human-created browsers are always ordinary visual Chromium/Chrome.
|
|
498
|
+
// Lightpanda remains an agent-only optimization requested explicitly
|
|
499
|
+
// through browser_open; if an agent creates one it is still visible in
|
|
500
|
+
// the shared BrowserSession switcher.
|
|
501
|
+
headless: false,
|
|
502
|
+
initialUrl: HUMAN_BROWSER_HOME_URL,
|
|
298
503
|
...(device
|
|
299
504
|
? {
|
|
300
|
-
headless: false,
|
|
301
505
|
placement: {
|
|
302
506
|
kind: "attached_device" as const,
|
|
303
507
|
deviceId: device.id,
|
|
@@ -306,7 +510,6 @@ export function BrowserViewer({
|
|
|
306
510
|
: {}),
|
|
307
511
|
...(linkedComputer
|
|
308
512
|
? {
|
|
309
|
-
headless: false,
|
|
310
513
|
linkedComputerSessionId: linkedComputer.id,
|
|
311
514
|
placement: linkedComputer.placement,
|
|
312
515
|
}
|
|
@@ -314,6 +517,12 @@ export function BrowserViewer({
|
|
|
314
517
|
...(identity ? { identityId: identity.id } : {}),
|
|
315
518
|
});
|
|
316
519
|
setSelection({ sessionId: response.session.id, pinned: true });
|
|
520
|
+
if (computerViewUnavailable) {
|
|
521
|
+
onNotify?.({
|
|
522
|
+
kind: "info",
|
|
523
|
+
message: "Browser opened. Computer view is unavailable on this placement.",
|
|
524
|
+
});
|
|
525
|
+
}
|
|
317
526
|
})()
|
|
318
527
|
.catch((cause) => notifyError(cause, "Could not open a browser."))
|
|
319
528
|
.finally(() => setCreating(false));
|
|
@@ -323,7 +532,8 @@ export function BrowserViewer({
|
|
|
323
532
|
createRegistryBrowser,
|
|
324
533
|
creating,
|
|
325
534
|
notifyError,
|
|
326
|
-
|
|
535
|
+
onNotify,
|
|
536
|
+
activeProfiles,
|
|
327
537
|
sessionId,
|
|
328
538
|
],
|
|
329
539
|
);
|
|
@@ -338,7 +548,17 @@ export function BrowserViewer({
|
|
|
338
548
|
if (!identity) {
|
|
339
549
|
const name = newProfileName?.trim() ?? "";
|
|
340
550
|
if (!name) return false;
|
|
341
|
-
|
|
551
|
+
// First-save is intentionally two-phase: the named identity exists
|
|
552
|
+
// before its first immutable revision. If capture/upload failed after
|
|
553
|
+
// that create, retry into the same empty identity instead of turning
|
|
554
|
+
// the recoverable draft into a permanent name conflict.
|
|
555
|
+
identity =
|
|
556
|
+
profiles.identities.find(
|
|
557
|
+
(candidate) =>
|
|
558
|
+
candidate.status === "active" &&
|
|
559
|
+
candidate.revisionCount === 0 &&
|
|
560
|
+
candidate.name.localeCompare(name, undefined, { sensitivity: "base" }) === 0,
|
|
561
|
+
) ?? (await profiles.create({ name })).identity;
|
|
342
562
|
}
|
|
343
563
|
const response = await profiles.publish(session.id, {
|
|
344
564
|
identityId: identity.id,
|
|
@@ -346,6 +566,17 @@ export function BrowserViewer({
|
|
|
346
566
|
advanceDefault: true,
|
|
347
567
|
});
|
|
348
568
|
setBaseRevisionOrdinal(response.revision.ordinal);
|
|
569
|
+
setProfileHistory((current) => ({
|
|
570
|
+
identityId: response.identity.id,
|
|
571
|
+
loading: false,
|
|
572
|
+
revisions:
|
|
573
|
+
current?.identityId === response.identity.id
|
|
574
|
+
? [
|
|
575
|
+
...current.revisions.filter((revision) => revision.id !== response.revision.id),
|
|
576
|
+
response.revision,
|
|
577
|
+
]
|
|
578
|
+
: [response.revision],
|
|
579
|
+
}));
|
|
349
580
|
await Promise.all([browser.refresh(), registry.refresh()]);
|
|
350
581
|
onNotify?.({
|
|
351
582
|
kind: "info",
|
|
@@ -365,6 +596,46 @@ export function BrowserViewer({
|
|
|
365
596
|
[browser, notifyError, onNotify, profiles, registry, savingProfile, selectedProfile],
|
|
366
597
|
);
|
|
367
598
|
|
|
599
|
+
const updateProfile = useCallback(
|
|
600
|
+
async (
|
|
601
|
+
identity: BrowserIdentity,
|
|
602
|
+
update: { name?: string; status?: "active" | "archived"; defaultRevisionId?: string },
|
|
603
|
+
): Promise<boolean> => {
|
|
604
|
+
if (savingProfile) return false;
|
|
605
|
+
setSavingProfile(true);
|
|
606
|
+
try {
|
|
607
|
+
const response = await profiles.update(identity.id, {
|
|
608
|
+
expectedVersion: identity.version,
|
|
609
|
+
...update,
|
|
610
|
+
});
|
|
611
|
+
if (update.defaultRevisionId) {
|
|
612
|
+
const ordinal = profileHistory?.revisions.find(
|
|
613
|
+
(revision) => revision.id === update.defaultRevisionId,
|
|
614
|
+
)?.ordinal;
|
|
615
|
+
onNotify?.({
|
|
616
|
+
kind: "info",
|
|
617
|
+
message: `${response.identity.name}${ordinal ? ` version ${ordinal}` : ""} will open by default in future browsers.`,
|
|
618
|
+
});
|
|
619
|
+
} else if (update.status === "archived") {
|
|
620
|
+
onNotify?.({
|
|
621
|
+
kind: "info",
|
|
622
|
+
message: `${response.identity.name} hidden from new browsers. Existing browsers are unchanged.`,
|
|
623
|
+
});
|
|
624
|
+
} else if (update.status === "active") {
|
|
625
|
+
onNotify?.({ kind: "info", message: `${response.identity.name} restored.` });
|
|
626
|
+
}
|
|
627
|
+
return true;
|
|
628
|
+
} catch (cause) {
|
|
629
|
+
notifyError(cause, "Could not update this browser profile.");
|
|
630
|
+
await profiles.refresh().catch(() => undefined);
|
|
631
|
+
return false;
|
|
632
|
+
} finally {
|
|
633
|
+
setSavingProfile(false);
|
|
634
|
+
}
|
|
635
|
+
},
|
|
636
|
+
[notifyError, onNotify, profileHistory?.revisions, profiles, savingProfile],
|
|
637
|
+
);
|
|
638
|
+
|
|
368
639
|
if (!enabled) return null;
|
|
369
640
|
if (registry.loading && liveSessions.length === 0) {
|
|
370
641
|
return (
|
|
@@ -393,7 +664,9 @@ export function BrowserViewer({
|
|
|
393
664
|
attachedDevices={attached.devices}
|
|
394
665
|
identities={profiles.identities}
|
|
395
666
|
creating={creating}
|
|
667
|
+
mutatingProfile={savingProfile}
|
|
396
668
|
onCreate={createBrowser}
|
|
669
|
+
onRestore={(identity) => updateProfile(identity, { status: "active" })}
|
|
397
670
|
prominent
|
|
398
671
|
/>
|
|
399
672
|
{registry.error ? (
|
|
@@ -405,7 +678,9 @@ export function BrowserViewer({
|
|
|
405
678
|
}
|
|
406
679
|
|
|
407
680
|
return (
|
|
408
|
-
<div
|
|
681
|
+
<div
|
|
682
|
+
className={cn("relative flex h-full min-h-0 flex-col overflow-hidden bg-og-bg", className)}
|
|
683
|
+
>
|
|
409
684
|
<BrowserToolbar
|
|
410
685
|
sessions={liveSessions}
|
|
411
686
|
relevantSessionIds={new Set(relevant.map((session) => session.id))}
|
|
@@ -414,9 +689,11 @@ export function BrowserViewer({
|
|
|
414
689
|
attachedDevices={attached.devices}
|
|
415
690
|
selectedProfile={selectedProfile}
|
|
416
691
|
baseRevisionOrdinal={baseRevisionOrdinal}
|
|
692
|
+
profileHistory={profileHistory}
|
|
417
693
|
creating={creating}
|
|
418
694
|
savingProfile={savingProfile}
|
|
419
695
|
refreshing={registry.refreshing || attached.refreshing}
|
|
696
|
+
interventionCounts={interventionCounts}
|
|
420
697
|
onSelect={(browserSessionId) => setSelection({ sessionId: browserSessionId, pinned: true })}
|
|
421
698
|
onFollow={() => {
|
|
422
699
|
const preferred = relevant[0];
|
|
@@ -424,9 +701,27 @@ export function BrowserViewer({
|
|
|
424
701
|
}}
|
|
425
702
|
onCreate={createBrowser}
|
|
426
703
|
onSaveProfile={saveProfileVersion}
|
|
704
|
+
onUpdateProfile={updateProfile}
|
|
427
705
|
onRefresh={() => void Promise.all([registry.refresh(), attached.refresh()])}
|
|
428
706
|
/>
|
|
429
|
-
|
|
707
|
+
<InteractionInterventionBanner
|
|
708
|
+
interventions={selectedInterventions}
|
|
709
|
+
activeTargetId={browser.selectedTarget?.id ?? null}
|
|
710
|
+
mutating={interventions.mutating}
|
|
711
|
+
onOpen={(intervention) =>
|
|
712
|
+
void browser
|
|
713
|
+
.selectTarget(intervention.targetId)
|
|
714
|
+
.catch((cause) => notifyError(cause, "Could not open the requested browser tab."))
|
|
715
|
+
}
|
|
716
|
+
onResolve={resolveIntervention}
|
|
717
|
+
/>
|
|
718
|
+
{!selectedRegistrySession ? (
|
|
719
|
+
<BrowserUnselectedPanel
|
|
720
|
+
peerCount={liveSessions.length}
|
|
721
|
+
creating={creating}
|
|
722
|
+
onCreate={() => createBrowser()}
|
|
723
|
+
/>
|
|
724
|
+
) : !controllerReady ? (
|
|
430
725
|
<BrowserLifecyclePanel
|
|
431
726
|
session={selectedRegistrySession}
|
|
432
727
|
attempt={
|
|
@@ -439,7 +734,8 @@ export function BrowserViewer({
|
|
|
439
734
|
<BrowserTabs
|
|
440
735
|
targets={browser.targets}
|
|
441
736
|
selectedTargetId={browser.selectedTarget?.id ?? null}
|
|
442
|
-
mutating={
|
|
737
|
+
mutating={savingProfile}
|
|
738
|
+
tabControl={browser.session?.capabilities.tabs === true}
|
|
443
739
|
onSelect={(targetId) =>
|
|
444
740
|
void browser
|
|
445
741
|
.selectTarget(targetId)
|
|
@@ -452,7 +748,7 @@ export function BrowserViewer({
|
|
|
452
748
|
}
|
|
453
749
|
onOpen={() =>
|
|
454
750
|
void browser
|
|
455
|
-
.openTarget()
|
|
751
|
+
.openTarget(HUMAN_BROWSER_HOME_URL)
|
|
456
752
|
.catch((cause) => notifyError(cause, "Could not open a tab."))
|
|
457
753
|
}
|
|
458
754
|
/>
|
|
@@ -475,15 +771,22 @@ export function BrowserViewer({
|
|
|
475
771
|
<BrowserViewport
|
|
476
772
|
frame={displayedFrame}
|
|
477
773
|
connectionState={displayConnectionState}
|
|
774
|
+
supportsLiveFrames={supportsLiveFrames}
|
|
478
775
|
connectionError={frames.error}
|
|
479
776
|
observation={browser.observation}
|
|
480
777
|
mutating={browser.mutating || savingProfile}
|
|
481
778
|
activityLabel={savingProfile ? "Saving browser version…" : undefined}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
779
|
+
clipboardEnabled={browser.session?.capabilities.clipboard === true}
|
|
780
|
+
onAction={async (action, frame) => {
|
|
781
|
+
const receipt = frame
|
|
782
|
+
? await browser.actFromFrame(action, frame)
|
|
783
|
+
: await browser.act(action);
|
|
784
|
+
if (receipt.state !== "completed") {
|
|
785
|
+
throw new Error(receipt.error?.message ?? "Browser input did not complete.");
|
|
786
|
+
}
|
|
787
|
+
return receipt;
|
|
788
|
+
}}
|
|
789
|
+
onReadClipboard={browser.readClipboard}
|
|
487
790
|
onReconnect={frames.reconnect}
|
|
488
791
|
onError={(cause) => notifyError(cause, "Browser input failed.")}
|
|
489
792
|
/>
|
|
@@ -494,6 +797,7 @@ export function BrowserViewer({
|
|
|
494
797
|
observation={browser.observation}
|
|
495
798
|
connectionState={displayConnectionState}
|
|
496
799
|
refreshing={registry.refreshing}
|
|
800
|
+
diagnosticsOpen={diagnosticsView !== null}
|
|
497
801
|
onOpenComputer={
|
|
498
802
|
browser.session?.linkedComputerSessionId && onOpenComputer
|
|
499
803
|
? () => {
|
|
@@ -502,23 +806,30 @@ export function BrowserViewer({
|
|
|
502
806
|
}
|
|
503
807
|
: undefined
|
|
504
808
|
}
|
|
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
|
-
}
|
|
809
|
+
onDiagnostics={diagnosticsView ? closeDiagnostics : loadDiagnostics}
|
|
519
810
|
/>
|
|
520
811
|
</>
|
|
521
812
|
)}
|
|
813
|
+
{diagnosticsView ? (
|
|
814
|
+
<BrowserDiagnosticsDrawer
|
|
815
|
+
session={browser.session}
|
|
816
|
+
profile={selectedProfile}
|
|
817
|
+
target={browser.selectedTarget}
|
|
818
|
+
observation={browser.observation}
|
|
819
|
+
baseRevisionOrdinal={baseRevisionOrdinal}
|
|
820
|
+
state={diagnosticsView}
|
|
821
|
+
downloads={downloads.downloads}
|
|
822
|
+
downloadsEnabled={browser.session?.capabilities.downloads === true}
|
|
823
|
+
downloadsLoading={downloads.loading}
|
|
824
|
+
downloadsRefreshing={downloads.refreshing}
|
|
825
|
+
downloadsError={downloads.error}
|
|
826
|
+
savingDownloadIds={downloads.savingDownloadIds}
|
|
827
|
+
onSaveDownload={saveDownload}
|
|
828
|
+
onRefreshDownloads={() => void downloads.refresh()}
|
|
829
|
+
onRefresh={refreshDiagnostics}
|
|
830
|
+
onClose={closeDiagnostics}
|
|
831
|
+
/>
|
|
832
|
+
) : null}
|
|
522
833
|
</div>
|
|
523
834
|
);
|
|
524
835
|
}
|
|
@@ -577,13 +888,19 @@ function BrowserToolbar(props: {
|
|
|
577
888
|
identities: BrowserIdentity[];
|
|
578
889
|
selectedProfile: BrowserIdentity | null;
|
|
579
890
|
baseRevisionOrdinal: number | null;
|
|
891
|
+
profileHistory: { identityId: string; loading: boolean; revisions: BrowserRevision[] } | null;
|
|
580
892
|
creating: boolean;
|
|
581
893
|
savingProfile: boolean;
|
|
582
894
|
refreshing: boolean;
|
|
895
|
+
interventionCounts: Map<string, number>;
|
|
583
896
|
onSelect: (id: string) => void;
|
|
584
897
|
onFollow: () => void;
|
|
585
898
|
onCreate: (choice?: BrowserLaunchChoice) => void;
|
|
586
899
|
onSaveProfile: (newProfileName?: string) => Promise<boolean>;
|
|
900
|
+
onUpdateProfile: (
|
|
901
|
+
identity: BrowserIdentity,
|
|
902
|
+
update: { name?: string; status?: "active" | "archived"; defaultRevisionId?: string },
|
|
903
|
+
) => Promise<boolean>;
|
|
587
904
|
onRefresh: () => void;
|
|
588
905
|
}) {
|
|
589
906
|
const detailsRef = useRef<HTMLDetailsElement | null>(null);
|
|
@@ -600,6 +917,9 @@ function BrowserToolbar(props: {
|
|
|
600
917
|
<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
918
|
<Globe2Icon className="size-3.5 shrink-0 text-og-muted" />
|
|
602
919
|
<span className="truncate font-medium">{selected?.name ?? "Browser"}</span>
|
|
920
|
+
{(props.interventionCounts.get(selected?.id ?? "") ?? 0) > 0 ? (
|
|
921
|
+
<span className="size-1.5 shrink-0 rounded-full bg-og-status-waiting" />
|
|
922
|
+
) : null}
|
|
603
923
|
<ChevronDownIcon className="size-3 shrink-0 text-og-subtle" />
|
|
604
924
|
</summary>
|
|
605
925
|
<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">
|
|
@@ -608,13 +928,15 @@ function BrowserToolbar(props: {
|
|
|
608
928
|
sessions={current}
|
|
609
929
|
identities={props.identities}
|
|
610
930
|
selectedId={props.selectedSessionId}
|
|
931
|
+
interventionCounts={props.interventionCounts}
|
|
611
932
|
onSelect={choose}
|
|
612
933
|
/>
|
|
613
934
|
<BrowserSessionGroup
|
|
614
|
-
label="
|
|
935
|
+
label="Workspace browsers"
|
|
615
936
|
sessions={others}
|
|
616
937
|
identities={props.identities}
|
|
617
938
|
selectedId={props.selectedSessionId}
|
|
939
|
+
interventionCounts={props.interventionCounts}
|
|
618
940
|
onSelect={choose}
|
|
619
941
|
/>
|
|
620
942
|
<div className="mt-1 flex gap-1 border-t border-og-border pt-1">
|
|
@@ -634,8 +956,10 @@ function BrowserToolbar(props: {
|
|
|
634
956
|
session={selected ?? null}
|
|
635
957
|
identity={props.selectedProfile}
|
|
636
958
|
baseRevisionOrdinal={props.baseRevisionOrdinal}
|
|
959
|
+
history={props.profileHistory}
|
|
637
960
|
saving={props.savingProfile}
|
|
638
961
|
onSave={props.onSaveProfile}
|
|
962
|
+
onUpdate={props.onUpdateProfile}
|
|
639
963
|
/>
|
|
640
964
|
<button
|
|
641
965
|
type="button"
|
|
@@ -649,17 +973,51 @@ function BrowserToolbar(props: {
|
|
|
649
973
|
attachedDevices={props.attachedDevices}
|
|
650
974
|
identities={props.identities}
|
|
651
975
|
creating={props.creating}
|
|
976
|
+
mutatingProfile={props.savingProfile}
|
|
652
977
|
onCreate={props.onCreate}
|
|
978
|
+
onRestore={(identity) => props.onUpdateProfile(identity, { status: "active" })}
|
|
653
979
|
/>
|
|
654
980
|
</div>
|
|
655
981
|
);
|
|
656
982
|
}
|
|
657
983
|
|
|
984
|
+
function BrowserUnselectedPanel(props: {
|
|
985
|
+
peerCount: number;
|
|
986
|
+
creating: boolean;
|
|
987
|
+
onCreate: () => void;
|
|
988
|
+
}) {
|
|
989
|
+
return (
|
|
990
|
+
<div className="grid min-h-0 flex-1 place-items-center bg-og-bg p-6 text-center">
|
|
991
|
+
<div className="max-w-sm">
|
|
992
|
+
<span className="mx-auto grid size-10 place-items-center rounded-og-md border border-og-border bg-og-surface-1 text-og-muted">
|
|
993
|
+
<Globe2Icon className="size-4.5" />
|
|
994
|
+
</span>
|
|
995
|
+
<p className="mt-3 text-og-menu font-medium text-og-fg">No browser for this agent</p>
|
|
996
|
+
<p className="mt-1 text-og-control leading-5 text-og-muted">
|
|
997
|
+
{props.peerCount === 1
|
|
998
|
+
? "One workspace browser is available from the browser menu."
|
|
999
|
+
: `${props.peerCount} workspace browsers are available from the browser menu.`}
|
|
1000
|
+
</p>
|
|
1001
|
+
<button
|
|
1002
|
+
type="button"
|
|
1003
|
+
disabled={props.creating}
|
|
1004
|
+
onClick={props.onCreate}
|
|
1005
|
+
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"
|
|
1006
|
+
>
|
|
1007
|
+
<PlusIcon className="size-3.5" />
|
|
1008
|
+
{props.creating ? "Opening…" : "New browser"}
|
|
1009
|
+
</button>
|
|
1010
|
+
</div>
|
|
1011
|
+
</div>
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
658
1015
|
function BrowserSessionGroup(props: {
|
|
659
1016
|
label: string;
|
|
660
1017
|
sessions: BrowserSession[];
|
|
661
1018
|
identities: BrowserIdentity[];
|
|
662
1019
|
selectedId: string | null;
|
|
1020
|
+
interventionCounts: Map<string, number>;
|
|
663
1021
|
onSelect: (id: string) => void;
|
|
664
1022
|
}) {
|
|
665
1023
|
if (props.sessions.length === 0) return null;
|
|
@@ -670,6 +1028,7 @@ function BrowserSessionGroup(props: {
|
|
|
670
1028
|
</p>
|
|
671
1029
|
{props.sessions.map((session) => {
|
|
672
1030
|
const identity = props.identities.find((candidate) => candidate.id === session.identityId);
|
|
1031
|
+
const interventionCount = props.interventionCounts.get(session.id) ?? 0;
|
|
673
1032
|
return (
|
|
674
1033
|
<button
|
|
675
1034
|
key={session.id}
|
|
@@ -693,6 +1052,11 @@ function BrowserSessionGroup(props: {
|
|
|
693
1052
|
{placementLabel(session)}
|
|
694
1053
|
</span>
|
|
695
1054
|
</span>
|
|
1055
|
+
{interventionCount > 0 ? (
|
|
1056
|
+
<span className="rounded-full bg-og-status-waiting/12 px-1.5 py-0.5 text-[10px] font-medium text-og-status-waiting">
|
|
1057
|
+
{interventionCount}
|
|
1058
|
+
</span>
|
|
1059
|
+
) : null}
|
|
696
1060
|
</button>
|
|
697
1061
|
);
|
|
698
1062
|
})}
|
|
@@ -704,7 +1068,9 @@ function BrowserLaunchMenu(props: {
|
|
|
704
1068
|
attachedDevices: AttachedBrowserDevice[];
|
|
705
1069
|
identities: BrowserIdentity[];
|
|
706
1070
|
creating: boolean;
|
|
1071
|
+
mutatingProfile: boolean;
|
|
707
1072
|
onCreate: (choice?: BrowserLaunchChoice) => void;
|
|
1073
|
+
onRestore: (identity: BrowserIdentity) => Promise<boolean>;
|
|
708
1074
|
prominent?: boolean;
|
|
709
1075
|
}) {
|
|
710
1076
|
const detailsRef = useRef<HTMLDetailsElement | null>(null);
|
|
@@ -712,22 +1078,28 @@ function BrowserLaunchMenu(props: {
|
|
|
712
1078
|
detailsRef.current?.removeAttribute("open");
|
|
713
1079
|
props.onCreate(choice);
|
|
714
1080
|
};
|
|
1081
|
+
const restore = async (identity: BrowserIdentity) => {
|
|
1082
|
+
if (await props.onRestore(identity)) detailsRef.current?.removeAttribute("open");
|
|
1083
|
+
};
|
|
1084
|
+
const busy = props.creating || props.mutatingProfile;
|
|
1085
|
+
const activeIdentities = props.identities.filter((identity) => identity.status === "active");
|
|
1086
|
+
const archivedIdentities = props.identities.filter((identity) => identity.status === "archived");
|
|
715
1087
|
return (
|
|
716
1088
|
<details ref={detailsRef} className={cn("relative", props.prominent && "mt-4 inline-block")}>
|
|
717
1089
|
<summary
|
|
718
1090
|
onClick={(event) => {
|
|
719
|
-
if (
|
|
1091
|
+
if (busy) event.preventDefault();
|
|
720
1092
|
}}
|
|
721
1093
|
className={cn(
|
|
722
1094
|
"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
1095
|
props.prominent
|
|
724
1096
|
? "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
1097
|
: "grid size-7 place-items-center rounded-og-sm",
|
|
726
|
-
|
|
1098
|
+
busy && "pointer-events-none opacity-50",
|
|
727
1099
|
)}
|
|
728
1100
|
aria-label="New browser"
|
|
729
1101
|
>
|
|
730
|
-
{
|
|
1102
|
+
{busy ? (
|
|
731
1103
|
<LoaderCircleIcon className="size-3.5 animate-spin" />
|
|
732
1104
|
) : (
|
|
733
1105
|
<PlusIcon className="size-3.5" />
|
|
@@ -783,13 +1155,13 @@ function BrowserLaunchMenu(props: {
|
|
|
783
1155
|
>
|
|
784
1156
|
<Globe2Icon className="size-3.5 text-og-muted" />
|
|
785
1157
|
<span>
|
|
786
|
-
<span className="block text-og-control text-og-fg">
|
|
787
|
-
<span className="block text-og-xs text-og-subtle">
|
|
1158
|
+
<span className="block text-og-control text-og-fg">Fresh browser</span>
|
|
1159
|
+
<span className="block text-og-xs text-og-subtle">Visual browser · no saved state</span>
|
|
788
1160
|
</span>
|
|
789
1161
|
</button>
|
|
790
|
-
{
|
|
1162
|
+
{activeIdentities.length > 0 ? (
|
|
791
1163
|
<div className="mt-1 border-t border-og-border pt-1">
|
|
792
|
-
{
|
|
1164
|
+
{activeIdentities.map((identity) => (
|
|
793
1165
|
<button
|
|
794
1166
|
key={identity.id}
|
|
795
1167
|
type="button"
|
|
@@ -809,6 +1181,28 @@ function BrowserLaunchMenu(props: {
|
|
|
809
1181
|
))}
|
|
810
1182
|
</div>
|
|
811
1183
|
) : null}
|
|
1184
|
+
{archivedIdentities.length > 0 ? (
|
|
1185
|
+
<details className="mt-1 border-t border-og-border pt-1">
|
|
1186
|
+
<summary className="flex cursor-pointer list-none items-center gap-1.5 rounded-og-sm px-2 py-1.5 text-og-xs text-og-subtle transition hover:bg-og-surface-2 hover:text-og-fg [&::-webkit-details-marker]:hidden">
|
|
1187
|
+
<ArchiveIcon className="size-3" /> Hidden profiles · {archivedIdentities.length}
|
|
1188
|
+
<ChevronDownIcon className="ml-auto size-3" />
|
|
1189
|
+
</summary>
|
|
1190
|
+
{archivedIdentities.map((identity) => (
|
|
1191
|
+
<button
|
|
1192
|
+
key={identity.id}
|
|
1193
|
+
type="button"
|
|
1194
|
+
disabled={busy}
|
|
1195
|
+
onClick={() => void restore(identity)}
|
|
1196
|
+
className="flex w-full items-center gap-2 rounded-og-sm px-2 py-2 text-left transition hover:bg-og-surface-2 disabled:opacity-50"
|
|
1197
|
+
>
|
|
1198
|
+
<RotateCcwIcon className="size-3.5 text-og-muted" />
|
|
1199
|
+
<span className="min-w-0 flex-1 truncate text-og-control text-og-fg">
|
|
1200
|
+
Restore {identity.name}
|
|
1201
|
+
</span>
|
|
1202
|
+
</button>
|
|
1203
|
+
))}
|
|
1204
|
+
</details>
|
|
1205
|
+
) : null}
|
|
812
1206
|
</div>
|
|
813
1207
|
</details>
|
|
814
1208
|
);
|
|
@@ -818,14 +1212,25 @@ function BrowserProfileMenu(props: {
|
|
|
818
1212
|
session: BrowserSession | null;
|
|
819
1213
|
identity: BrowserIdentity | null;
|
|
820
1214
|
baseRevisionOrdinal: number | null;
|
|
1215
|
+
history: { identityId: string; loading: boolean; revisions: BrowserRevision[] } | null;
|
|
821
1216
|
saving: boolean;
|
|
822
1217
|
onSave: (newProfileName?: string) => Promise<boolean>;
|
|
1218
|
+
onUpdate: (
|
|
1219
|
+
identity: BrowserIdentity,
|
|
1220
|
+
update: { name?: string; status?: "active" | "archived"; defaultRevisionId?: string },
|
|
1221
|
+
) => Promise<boolean>;
|
|
823
1222
|
}) {
|
|
824
1223
|
const detailsRef = useRef<HTMLDetailsElement | null>(null);
|
|
825
1224
|
const nameInputId = useId();
|
|
826
1225
|
const [name, setName] = useState("");
|
|
827
1226
|
const attached = props.session?.placement.kind === "attached_device";
|
|
828
|
-
const canSave =
|
|
1227
|
+
const canSave =
|
|
1228
|
+
(props.session?.capabilities.identityPublication ?? false) &&
|
|
1229
|
+
props.identity?.status !== "archived";
|
|
1230
|
+
const revisions =
|
|
1231
|
+
props.identity && props.history?.identityId === props.identity.id
|
|
1232
|
+
? [...props.history.revisions].sort((left, right) => right.ordinal - left.ordinal)
|
|
1233
|
+
: [];
|
|
829
1234
|
const save = async (newProfileName?: string) => {
|
|
830
1235
|
if (await props.onSave(newProfileName)) {
|
|
831
1236
|
setName("");
|
|
@@ -875,14 +1280,65 @@ function BrowserProfileMenu(props: {
|
|
|
875
1280
|
<p className="mt-0.5 text-og-xs leading-4 text-og-subtle">
|
|
876
1281
|
{attached
|
|
877
1282
|
? "This session drives the existing Chrome profile and its current login state."
|
|
878
|
-
: props.identity
|
|
879
|
-
?
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
1283
|
+
: props.identity?.status === "archived"
|
|
1284
|
+
? "Hidden from new browsers. This already-open browser is unchanged."
|
|
1285
|
+
: props.identity
|
|
1286
|
+
? props.baseRevisionOrdinal
|
|
1287
|
+
? `Started from version ${props.baseRevisionOrdinal}.`
|
|
1288
|
+
: "Not saved yet."
|
|
1289
|
+
: "This browser is not reusable yet."}
|
|
883
1290
|
</p>
|
|
884
1291
|
</div>
|
|
885
1292
|
</div>
|
|
1293
|
+
{props.identity && revisions.length > 0 ? (
|
|
1294
|
+
<div className="mt-3 border-t border-og-border pt-2">
|
|
1295
|
+
<p className="px-1 text-[10px] font-medium uppercase tracking-[0.12em] text-og-subtle">
|
|
1296
|
+
Saved versions
|
|
1297
|
+
</p>
|
|
1298
|
+
<div className="mt-1 max-h-40 overflow-y-auto">
|
|
1299
|
+
{revisions.map((revision) => {
|
|
1300
|
+
const isDefault = revision.id === props.identity?.defaultRevisionId;
|
|
1301
|
+
const isCurrent = revision.ordinal === props.baseRevisionOrdinal;
|
|
1302
|
+
return (
|
|
1303
|
+
<button
|
|
1304
|
+
key={revision.id}
|
|
1305
|
+
type="button"
|
|
1306
|
+
disabled={isDefault || props.saving || props.identity?.status === "archived"}
|
|
1307
|
+
onClick={() =>
|
|
1308
|
+
void props.onUpdate(props.identity!, { defaultRevisionId: revision.id })
|
|
1309
|
+
}
|
|
1310
|
+
className="flex w-full items-center gap-2 rounded-og-sm px-2 py-1.5 text-left transition hover:bg-og-surface-2 disabled:cursor-default disabled:opacity-70"
|
|
1311
|
+
>
|
|
1312
|
+
<span className="grid size-4 place-items-center text-og-muted">
|
|
1313
|
+
{isDefault ? <CheckIcon className="size-3.5 text-og-status-running" /> : null}
|
|
1314
|
+
</span>
|
|
1315
|
+
<span className="min-w-0 flex-1 text-og-control text-og-fg">
|
|
1316
|
+
Version {revision.ordinal}
|
|
1317
|
+
</span>
|
|
1318
|
+
<span className="text-[10px] text-og-subtle">
|
|
1319
|
+
{isCurrent && isDefault
|
|
1320
|
+
? "current · default"
|
|
1321
|
+
: isDefault
|
|
1322
|
+
? "default"
|
|
1323
|
+
: isCurrent
|
|
1324
|
+
? "current · Use by default"
|
|
1325
|
+
: "Use by default"}
|
|
1326
|
+
</span>
|
|
1327
|
+
</button>
|
|
1328
|
+
);
|
|
1329
|
+
})}
|
|
1330
|
+
</div>
|
|
1331
|
+
<p className="mt-1 px-2 text-[10px] leading-4 text-og-subtle">
|
|
1332
|
+
Changing the default affects only browsers opened later.
|
|
1333
|
+
</p>
|
|
1334
|
+
</div>
|
|
1335
|
+
) : props.identity &&
|
|
1336
|
+
props.history?.identityId === props.identity.id &&
|
|
1337
|
+
props.history.loading ? (
|
|
1338
|
+
<p className="mt-3 flex items-center gap-1.5 border-t border-og-border px-2 pt-2 text-og-xs text-og-subtle">
|
|
1339
|
+
<LoaderCircleIcon className="size-3 animate-spin" /> Loading saved versions…
|
|
1340
|
+
</p>
|
|
1341
|
+
) : null}
|
|
886
1342
|
{canSave ? (
|
|
887
1343
|
props.identity ? (
|
|
888
1344
|
<button
|
|
@@ -916,7 +1372,7 @@ function BrowserProfileMenu(props: {
|
|
|
916
1372
|
onInput={(event) => setName(event.currentTarget.value)}
|
|
917
1373
|
maxLength={200}
|
|
918
1374
|
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-
|
|
1375
|
+
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-hidden focus-visible:ring-2 focus-visible:ring-og-accent"
|
|
920
1376
|
/>
|
|
921
1377
|
<button
|
|
922
1378
|
type="submit"
|
|
@@ -937,9 +1393,30 @@ function BrowserProfileMenu(props: {
|
|
|
937
1393
|
<p className="mt-3 rounded-og-sm bg-og-surface-2 px-2.5 py-2 text-og-xs text-og-subtle">
|
|
938
1394
|
{attached
|
|
939
1395
|
? "Chrome keeps this profile's state directly; OpenGeni does not copy it automatically."
|
|
940
|
-
:
|
|
1396
|
+
: props.identity?.status === "archived"
|
|
1397
|
+
? "Restore this profile before saving another version."
|
|
1398
|
+
: "This browser cannot save reusable profile state."}
|
|
941
1399
|
</p>
|
|
942
1400
|
)}
|
|
1401
|
+
{!attached && props.identity ? (
|
|
1402
|
+
<button
|
|
1403
|
+
type="button"
|
|
1404
|
+
disabled={props.saving}
|
|
1405
|
+
onClick={() =>
|
|
1406
|
+
void props.onUpdate(props.identity!, {
|
|
1407
|
+
status: props.identity!.status === "active" ? "archived" : "active",
|
|
1408
|
+
})
|
|
1409
|
+
}
|
|
1410
|
+
className="mt-2 flex h-8 w-full items-center justify-center gap-1.5 rounded-og-sm px-3 text-og-control text-og-muted transition hover:bg-og-surface-2 hover:text-og-fg disabled:opacity-50"
|
|
1411
|
+
>
|
|
1412
|
+
{props.identity.status === "active" ? (
|
|
1413
|
+
<ArchiveIcon className="size-3.5" />
|
|
1414
|
+
) : (
|
|
1415
|
+
<RotateCcwIcon className="size-3.5" />
|
|
1416
|
+
)}
|
|
1417
|
+
{props.identity.status === "active" ? "Hide from new browsers" : "Restore profile"}
|
|
1418
|
+
</button>
|
|
1419
|
+
) : null}
|
|
943
1420
|
{canSave ? (
|
|
944
1421
|
<p className="mt-2 text-[10px] leading-4 text-og-subtle">
|
|
945
1422
|
Saving briefly restarts this browser. Other open browsers are unchanged.
|
|
@@ -967,6 +1444,7 @@ function BrowserTabs(props: {
|
|
|
967
1444
|
targets: BrowserTarget[];
|
|
968
1445
|
selectedTargetId: string | null;
|
|
969
1446
|
mutating: boolean;
|
|
1447
|
+
tabControl: boolean;
|
|
970
1448
|
onSelect: (id: string) => void;
|
|
971
1449
|
onClose: (id: string) => void;
|
|
972
1450
|
onOpen: () => void;
|
|
@@ -991,26 +1469,30 @@ function BrowserTabs(props: {
|
|
|
991
1469
|
>
|
|
992
1470
|
{target.title || shortUrl(target.url)}
|
|
993
1471
|
</button>
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1472
|
+
{props.tabControl ? (
|
|
1473
|
+
<button
|
|
1474
|
+
type="button"
|
|
1475
|
+
onClick={() => props.onClose(target.id)}
|
|
1476
|
+
disabled={props.mutating}
|
|
1477
|
+
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"
|
|
1478
|
+
aria-label={`Close ${target.title || "tab"}`}
|
|
1479
|
+
>
|
|
1480
|
+
<XIcon className="size-3" />
|
|
1481
|
+
</button>
|
|
1482
|
+
) : null}
|
|
1003
1483
|
</div>
|
|
1004
1484
|
))}
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1485
|
+
{props.tabControl ? (
|
|
1486
|
+
<button
|
|
1487
|
+
type="button"
|
|
1488
|
+
onClick={props.onOpen}
|
|
1489
|
+
disabled={props.mutating}
|
|
1490
|
+
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"
|
|
1491
|
+
aria-label="New tab"
|
|
1492
|
+
>
|
|
1493
|
+
<PlusIcon className="size-3.5" />
|
|
1494
|
+
</button>
|
|
1495
|
+
) : null}
|
|
1014
1496
|
</div>
|
|
1015
1497
|
);
|
|
1016
1498
|
}
|
|
@@ -1056,8 +1538,14 @@ function BrowserAddressBar(props: {
|
|
|
1056
1538
|
onBlur={() => {
|
|
1057
1539
|
focusedRef.current = false;
|
|
1058
1540
|
}}
|
|
1541
|
+
onKeyDown={(event) => {
|
|
1542
|
+
if (event.key !== "Enter") return;
|
|
1543
|
+
event.preventDefault();
|
|
1544
|
+
const normalized = normalizeBrowserAddress(draft);
|
|
1545
|
+
if (normalized) props.onNavigate(normalized);
|
|
1546
|
+
}}
|
|
1059
1547
|
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-
|
|
1548
|
+
className="min-w-0 flex-1 bg-transparent text-og-control text-og-fg placeholder:text-og-subtle focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent disabled:opacity-50"
|
|
1061
1549
|
aria-label="Address"
|
|
1062
1550
|
spellCheck={false}
|
|
1063
1551
|
placeholder="Search or enter address"
|
|
@@ -1070,18 +1558,21 @@ function BrowserAddressBar(props: {
|
|
|
1070
1558
|
function BrowserViewport(props: {
|
|
1071
1559
|
frame: BrowserFrame | null;
|
|
1072
1560
|
connectionState: string;
|
|
1561
|
+
supportsLiveFrames: boolean;
|
|
1073
1562
|
connectionError: Error | null;
|
|
1074
1563
|
observation: ReturnType<typeof useBrowserSession>["observation"];
|
|
1075
1564
|
mutating: boolean;
|
|
1565
|
+
clipboardEnabled: boolean;
|
|
1076
1566
|
activityLabel?: string | undefined;
|
|
1077
|
-
onAction: (action: BrowserAction, frame: BrowserFrame | null) => Promise<
|
|
1567
|
+
onAction: (action: BrowserAction, frame: BrowserFrame | null) => Promise<BrowserActionReceipt>;
|
|
1568
|
+
onReadClipboard: () => Promise<{ text: string }>;
|
|
1078
1569
|
onReconnect: () => void;
|
|
1079
1570
|
onError: (cause: unknown) => void;
|
|
1080
1571
|
}) {
|
|
1081
1572
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
1082
1573
|
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
|
1574
|
+
const composingRef = useRef(false);
|
|
1083
1575
|
const pointerStartRef = useRef<PointerStart | null>(null);
|
|
1084
|
-
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
1085
1576
|
const lastClickRef = useRef<{
|
|
1086
1577
|
at: number;
|
|
1087
1578
|
x: number;
|
|
@@ -1096,60 +1587,106 @@ function BrowserViewport(props: {
|
|
|
1096
1587
|
frame: BrowserFrame;
|
|
1097
1588
|
timer: ReturnType<typeof setTimeout> | null;
|
|
1098
1589
|
} | null>(null);
|
|
1590
|
+
const pendingTextRef = useRef<{
|
|
1591
|
+
text: string;
|
|
1592
|
+
frame: BrowserFrame | null;
|
|
1593
|
+
timer: ReturnType<typeof setTimeout>;
|
|
1594
|
+
} | null>(null);
|
|
1099
1595
|
const actionRef = useRef(props.onAction);
|
|
1596
|
+
const readClipboardRef = useRef(props.onReadClipboard);
|
|
1100
1597
|
const errorRef = useRef(props.onError);
|
|
1101
1598
|
const actionTailRef = useRef<Promise<void>>(Promise.resolve());
|
|
1599
|
+
const queuedFrameRef = useRef<BrowserFrame | null>(null);
|
|
1600
|
+
const decodingFrameRef = useRef(false);
|
|
1601
|
+
const mountedRef = useRef(true);
|
|
1102
1602
|
actionRef.current = props.onAction;
|
|
1603
|
+
readClipboardRef.current = props.onReadClipboard;
|
|
1103
1604
|
errorRef.current = props.onError;
|
|
1104
1605
|
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
if (!frame || !canvas) return;
|
|
1109
|
-
let cancelled = false;
|
|
1110
|
-
let objectUrl: string | null = null;
|
|
1606
|
+
const paintQueuedFrames = useCallback(() => {
|
|
1607
|
+
if (decodingFrameRef.current) return;
|
|
1608
|
+
decodingFrameRef.current = true;
|
|
1111
1609
|
void (async () => {
|
|
1112
1610
|
try {
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1611
|
+
while (mountedRef.current) {
|
|
1612
|
+
const frame = queuedFrameRef.current;
|
|
1613
|
+
queuedFrameRef.current = null;
|
|
1614
|
+
if (!frame) break;
|
|
1615
|
+
|
|
1616
|
+
let objectUrl: string | null = null;
|
|
1617
|
+
try {
|
|
1618
|
+
const blob = new Blob([frame.data.slice().buffer], {
|
|
1619
|
+
type: frame.mediaType,
|
|
1620
|
+
});
|
|
1621
|
+
if (typeof createImageBitmap === "function") {
|
|
1622
|
+
const bitmap = await createImageBitmap(blob);
|
|
1623
|
+
try {
|
|
1624
|
+
const canvas = canvasRef.current;
|
|
1625
|
+
if (mountedRef.current && canvas) {
|
|
1626
|
+
paintCanvas(canvas, bitmap, frame.width, frame.height);
|
|
1627
|
+
}
|
|
1628
|
+
} finally {
|
|
1629
|
+
bitmap.close();
|
|
1630
|
+
}
|
|
1631
|
+
continue;
|
|
1632
|
+
}
|
|
1633
|
+
objectUrl = URL.createObjectURL(blob);
|
|
1634
|
+
const image = await loadImage(objectUrl);
|
|
1635
|
+
const canvas = canvasRef.current;
|
|
1636
|
+
if (mountedRef.current && canvas) {
|
|
1637
|
+
paintCanvas(canvas, image, frame.width, frame.height);
|
|
1638
|
+
}
|
|
1639
|
+
} catch (cause) {
|
|
1640
|
+
if (mountedRef.current) errorRef.current(cause);
|
|
1641
|
+
} finally {
|
|
1642
|
+
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
1121
1643
|
}
|
|
1122
|
-
paintCanvas(canvas, bitmap, frame.width, frame.height);
|
|
1123
|
-
bitmap.close();
|
|
1124
|
-
return;
|
|
1125
1644
|
}
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
if (
|
|
1129
|
-
} catch (cause) {
|
|
1130
|
-
if (!cancelled) errorRef.current(cause);
|
|
1645
|
+
} finally {
|
|
1646
|
+
decodingFrameRef.current = false;
|
|
1647
|
+
if (mountedRef.current && queuedFrameRef.current) paintQueuedFrames();
|
|
1131
1648
|
}
|
|
1132
1649
|
})();
|
|
1650
|
+
}, []);
|
|
1651
|
+
|
|
1652
|
+
useEffect(() => {
|
|
1653
|
+
mountedRef.current = true;
|
|
1133
1654
|
return () => {
|
|
1134
|
-
|
|
1135
|
-
|
|
1655
|
+
mountedRef.current = false;
|
|
1656
|
+
queuedFrameRef.current = null;
|
|
1136
1657
|
};
|
|
1137
|
-
}, [
|
|
1658
|
+
}, []);
|
|
1659
|
+
|
|
1660
|
+
useEffect(() => {
|
|
1661
|
+
if (!props.frame) return;
|
|
1662
|
+
queuedFrameRef.current = props.frame;
|
|
1663
|
+
paintQueuedFrames();
|
|
1664
|
+
}, [paintQueuedFrames, props.frame]);
|
|
1138
1665
|
|
|
1139
1666
|
useEffect(
|
|
1140
1667
|
() => () => {
|
|
1141
|
-
if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
|
|
1142
1668
|
if (wheelRef.current?.timer) clearTimeout(wheelRef.current.timer);
|
|
1669
|
+
if (pendingTextRef.current?.timer) clearTimeout(pendingTextRef.current.timer);
|
|
1143
1670
|
},
|
|
1144
1671
|
[],
|
|
1145
1672
|
);
|
|
1146
1673
|
|
|
1147
|
-
const enqueue = useCallback(
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1674
|
+
const enqueue = useCallback(
|
|
1675
|
+
(
|
|
1676
|
+
action: BrowserAction,
|
|
1677
|
+
frame: BrowserFrame | null,
|
|
1678
|
+
after?: (receipt: BrowserActionReceipt) => Promise<void>,
|
|
1679
|
+
) => {
|
|
1680
|
+
actionTailRef.current = actionTailRef.current
|
|
1681
|
+
.catch(() => undefined)
|
|
1682
|
+
.then(async () => {
|
|
1683
|
+
const receipt = await actionRef.current(action, frame);
|
|
1684
|
+
await after?.(receipt);
|
|
1685
|
+
})
|
|
1686
|
+
.catch((cause) => errorRef.current(cause));
|
|
1687
|
+
},
|
|
1688
|
+
[],
|
|
1689
|
+
);
|
|
1153
1690
|
|
|
1154
1691
|
const point = useCallback(
|
|
1155
1692
|
(frame: BrowserFrame, clientX: number, clientY: number) =>
|
|
@@ -1157,8 +1694,26 @@ function BrowserViewport(props: {
|
|
|
1157
1694
|
[],
|
|
1158
1695
|
);
|
|
1159
1696
|
|
|
1697
|
+
const flushPendingText = useCallback(() => {
|
|
1698
|
+
const pending = pendingTextRef.current;
|
|
1699
|
+
if (!pending) return;
|
|
1700
|
+
clearTimeout(pending.timer);
|
|
1701
|
+
pendingTextRef.current = null;
|
|
1702
|
+
enqueue({ type: "type", text: pending.text }, pending.frame);
|
|
1703
|
+
}, [enqueue]);
|
|
1704
|
+
|
|
1705
|
+
const flushPendingClick = useCallback(() => {
|
|
1706
|
+
lastClickRef.current = null;
|
|
1707
|
+
}, []);
|
|
1708
|
+
|
|
1160
1709
|
const pointerDown = (event: PointerEvent<HTMLCanvasElement>) => {
|
|
1161
|
-
if (!props.frame ||
|
|
1710
|
+
if (!props.frame || event.button !== 0) return;
|
|
1711
|
+
// Keep the hidden keyboard sink focused after the browser performs the
|
|
1712
|
+
// pointerdown default action for the canvas. Without preventing that
|
|
1713
|
+
// default, Chrome immediately moves focus back to the document and all
|
|
1714
|
+
// subsequent typing/paste is silently lost.
|
|
1715
|
+
event.preventDefault();
|
|
1716
|
+
flushPendingText();
|
|
1162
1717
|
pointerStartRef.current = {
|
|
1163
1718
|
x: event.clientX,
|
|
1164
1719
|
y: event.clientY,
|
|
@@ -1177,6 +1732,7 @@ function BrowserViewport(props: {
|
|
|
1177
1732
|
const to = point(start.frame, event.clientX, event.clientY);
|
|
1178
1733
|
if (!from || !to) return;
|
|
1179
1734
|
if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > 5) {
|
|
1735
|
+
flushPendingClick();
|
|
1180
1736
|
enqueue(
|
|
1181
1737
|
{
|
|
1182
1738
|
type: "pointer",
|
|
@@ -1198,24 +1754,23 @@ function BrowserViewport(props: {
|
|
|
1198
1754
|
Math.hypot(previous.x - to.x, previous.y - to.y) < 6 &&
|
|
1199
1755
|
sameFrameFence(previous.frame, start.frame)
|
|
1200
1756
|
) {
|
|
1201
|
-
if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
|
|
1202
|
-
clickTimerRef.current = null;
|
|
1203
1757
|
lastClickRef.current = null;
|
|
1204
|
-
|
|
1758
|
+
// The first click was already dispatched immediately. Tell CDP this is
|
|
1759
|
+
// the continuation (clickCount=2), so the page receives a true dblclick
|
|
1760
|
+
// without making every ordinary click wait for the double-click window.
|
|
1761
|
+
enqueue({ type: "pointer", action: "click", clickCount: 2, x: to.x, y: to.y }, start.frame);
|
|
1205
1762
|
return;
|
|
1206
1763
|
}
|
|
1207
1764
|
lastClickRef.current = { at: now, x: to.x, y: to.y, frame: start.frame };
|
|
1208
|
-
|
|
1209
|
-
clickTimerRef.current = null;
|
|
1210
|
-
lastClickRef.current = null;
|
|
1211
|
-
enqueue({ type: "pointer", action: "click", x: to.x, y: to.y }, start.frame);
|
|
1212
|
-
}, 280);
|
|
1765
|
+
enqueue({ type: "pointer", action: "click", x: to.x, y: to.y }, start.frame);
|
|
1213
1766
|
};
|
|
1214
1767
|
|
|
1215
1768
|
const contextMenu = (event: MouseEvent<HTMLCanvasElement>) => {
|
|
1216
1769
|
event.preventDefault();
|
|
1217
1770
|
const frame = props.frame;
|
|
1218
1771
|
if (!frame) return;
|
|
1772
|
+
flushPendingText();
|
|
1773
|
+
flushPendingClick();
|
|
1219
1774
|
const at = point(frame, event.clientX, event.clientY);
|
|
1220
1775
|
if (at) enqueue({ type: "pointer", action: "click", x: at.x, y: at.y, button: "right" }, frame);
|
|
1221
1776
|
};
|
|
@@ -1225,6 +1780,8 @@ function BrowserViewport(props: {
|
|
|
1225
1780
|
if (!frame) return;
|
|
1226
1781
|
const at = point(frame, event.clientX, event.clientY);
|
|
1227
1782
|
if (!at) return;
|
|
1783
|
+
flushPendingText();
|
|
1784
|
+
flushPendingClick();
|
|
1228
1785
|
event.preventDefault();
|
|
1229
1786
|
const pending = wheelRef.current;
|
|
1230
1787
|
if (pending?.timer) clearTimeout(pending.timer);
|
|
@@ -1254,25 +1811,89 @@ function BrowserViewport(props: {
|
|
|
1254
1811
|
};
|
|
1255
1812
|
|
|
1256
1813
|
const keyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
1814
|
+
const command = event.metaKey || event.ctrlKey;
|
|
1815
|
+
if (
|
|
1816
|
+
props.clipboardEnabled &&
|
|
1817
|
+
command &&
|
|
1818
|
+
!event.altKey &&
|
|
1819
|
+
["c", "v"].includes(event.key.toLowerCase())
|
|
1820
|
+
) {
|
|
1821
|
+
flushPendingText();
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1257
1824
|
const key = browserKey(event);
|
|
1258
1825
|
if (!key) return;
|
|
1259
1826
|
event.preventDefault();
|
|
1827
|
+
flushPendingText();
|
|
1828
|
+
flushPendingClick();
|
|
1260
1829
|
enqueue({ type: "press", key }, props.frame);
|
|
1261
1830
|
};
|
|
1262
1831
|
|
|
1263
|
-
const
|
|
1832
|
+
const copy = (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
|
1833
|
+
if (!props.clipboardEnabled) return;
|
|
1834
|
+
event.preventDefault();
|
|
1835
|
+
flushPendingText();
|
|
1836
|
+
flushPendingClick();
|
|
1837
|
+
enqueue({ type: "clipboard", operation: "copy" }, props.frame, async () => {
|
|
1838
|
+
const clipboard = await readClipboardRef.current();
|
|
1839
|
+
if (clipboard.text.length === 0) return;
|
|
1840
|
+
if (!(await copyTextToClipboard(clipboard.text))) {
|
|
1841
|
+
throw new Error("Browser text could not be copied to the local clipboard");
|
|
1842
|
+
}
|
|
1843
|
+
});
|
|
1844
|
+
};
|
|
1845
|
+
|
|
1846
|
+
const paste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
|
1847
|
+
if (!props.clipboardEnabled) return;
|
|
1848
|
+
event.preventDefault();
|
|
1849
|
+
flushPendingText();
|
|
1850
|
+
flushPendingClick();
|
|
1851
|
+
enqueue(
|
|
1852
|
+
{
|
|
1853
|
+
type: "clipboard",
|
|
1854
|
+
operation: "paste",
|
|
1855
|
+
text: event.clipboardData.getData("text/plain"),
|
|
1856
|
+
},
|
|
1857
|
+
props.frame,
|
|
1858
|
+
);
|
|
1859
|
+
};
|
|
1860
|
+
|
|
1861
|
+
const input = (value: string, nativeComposing = false) => {
|
|
1862
|
+
if (composingRef.current || nativeComposing) return;
|
|
1264
1863
|
if (!value) return;
|
|
1265
|
-
|
|
1864
|
+
flushPendingClick();
|
|
1865
|
+
const pending = pendingTextRef.current;
|
|
1866
|
+
if (pending && sameOptionalBrowserFrame(pending.frame, props.frame)) {
|
|
1867
|
+
clearTimeout(pending.timer);
|
|
1868
|
+
pending.text += value;
|
|
1869
|
+
pending.timer = setTimeout(flushPendingText, 16);
|
|
1870
|
+
} else {
|
|
1871
|
+
flushPendingText();
|
|
1872
|
+
pendingTextRef.current = {
|
|
1873
|
+
text: value,
|
|
1874
|
+
frame: props.frame,
|
|
1875
|
+
timer: setTimeout(flushPendingText, 16),
|
|
1876
|
+
};
|
|
1877
|
+
}
|
|
1266
1878
|
if (inputRef.current) inputRef.current.value = "";
|
|
1267
1879
|
};
|
|
1268
1880
|
|
|
1881
|
+
const compositionStart = () => {
|
|
1882
|
+
composingRef.current = true;
|
|
1883
|
+
};
|
|
1884
|
+
|
|
1885
|
+
const compositionEnd = (event: CompositionEvent<HTMLTextAreaElement>) => {
|
|
1886
|
+
composingRef.current = false;
|
|
1887
|
+
input(event.currentTarget.value || event.data);
|
|
1888
|
+
};
|
|
1889
|
+
|
|
1269
1890
|
const showCanvas = props.frame !== null;
|
|
1270
1891
|
return (
|
|
1271
1892
|
<div className="relative min-h-0 flex-1 overflow-hidden bg-black">
|
|
1272
1893
|
<canvas
|
|
1273
1894
|
ref={canvasRef}
|
|
1274
1895
|
className={cn(
|
|
1275
|
-
"absolute inset-0 m-auto max-h-full max-w-full touch-none focus-visible:outline-
|
|
1896
|
+
"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",
|
|
1276
1897
|
!showCanvas && "invisible",
|
|
1277
1898
|
)}
|
|
1278
1899
|
onPointerDown={pointerDown}
|
|
@@ -1287,8 +1908,14 @@ function BrowserViewport(props: {
|
|
|
1287
1908
|
<textarea
|
|
1288
1909
|
ref={inputRef}
|
|
1289
1910
|
defaultValue=""
|
|
1290
|
-
onInput={(event) =>
|
|
1911
|
+
onInput={(event) =>
|
|
1912
|
+
input(event.currentTarget.value, (event.nativeEvent as InputEvent).isComposing)
|
|
1913
|
+
}
|
|
1914
|
+
onCompositionStart={compositionStart}
|
|
1915
|
+
onCompositionEnd={compositionEnd}
|
|
1291
1916
|
onKeyDown={keyDown}
|
|
1917
|
+
onCopy={copy}
|
|
1918
|
+
onPaste={paste}
|
|
1292
1919
|
className="pointer-events-none absolute left-1/2 top-1/2 size-px resize-none overflow-hidden opacity-0"
|
|
1293
1920
|
aria-label="Browser keyboard input"
|
|
1294
1921
|
autoCapitalize="off"
|
|
@@ -1299,6 +1926,7 @@ function BrowserViewport(props: {
|
|
|
1299
1926
|
<SemanticBrowserFallback
|
|
1300
1927
|
observation={props.observation}
|
|
1301
1928
|
connectionState={props.connectionState}
|
|
1929
|
+
supportsLiveFrames={props.supportsLiveFrames}
|
|
1302
1930
|
error={props.connectionError}
|
|
1303
1931
|
onAction={(action) => enqueue(action, null)}
|
|
1304
1932
|
onReconnect={props.onReconnect}
|
|
@@ -1316,6 +1944,7 @@ function BrowserViewport(props: {
|
|
|
1316
1944
|
function SemanticBrowserFallback(props: {
|
|
1317
1945
|
observation: ReturnType<typeof useBrowserSession>["observation"];
|
|
1318
1946
|
connectionState: string;
|
|
1947
|
+
supportsLiveFrames: boolean;
|
|
1319
1948
|
error: Error | null;
|
|
1320
1949
|
onAction: (action: BrowserAction) => void;
|
|
1321
1950
|
onReconnect: () => void;
|
|
@@ -1330,16 +1959,26 @@ function SemanticBrowserFallback(props: {
|
|
|
1330
1959
|
<div className="flex items-center gap-2">
|
|
1331
1960
|
{props.error ? (
|
|
1332
1961
|
<CircleAlertIcon className="size-4 text-og-status-error" />
|
|
1962
|
+
) : !props.supportsLiveFrames ? (
|
|
1963
|
+
<ZapIcon className="size-4 text-og-muted" />
|
|
1333
1964
|
) : (
|
|
1334
1965
|
<LoaderCircleIcon className="size-4 animate-spin text-og-muted" />
|
|
1335
1966
|
)}
|
|
1336
1967
|
<p className="text-og-menu font-medium text-og-fg">
|
|
1337
|
-
{props.error
|
|
1968
|
+
{props.error
|
|
1969
|
+
? "Live view disconnected"
|
|
1970
|
+
: props.supportsLiveFrames
|
|
1971
|
+
? browserConnectionLabel(props.connectionState)
|
|
1972
|
+
: "Semantic browser"}
|
|
1338
1973
|
</p>
|
|
1339
1974
|
</div>
|
|
1340
1975
|
{interactive.length > 0 ? (
|
|
1341
1976
|
<div className="mt-3 border-t border-og-border pt-3">
|
|
1342
|
-
<p className="mb-2 text-og-xs text-og-subtle">
|
|
1977
|
+
<p className="mb-2 text-og-xs text-og-subtle">
|
|
1978
|
+
{props.supportsLiveFrames
|
|
1979
|
+
? "Page controls remain available"
|
|
1980
|
+
: "Available page controls"}
|
|
1981
|
+
</p>
|
|
1343
1982
|
<div className="flex flex-wrap gap-1.5">
|
|
1344
1983
|
{interactive.map((node) => (
|
|
1345
1984
|
<button
|
|
@@ -1359,7 +1998,7 @@ function SemanticBrowserFallback(props: {
|
|
|
1359
1998
|
</div>
|
|
1360
1999
|
</div>
|
|
1361
2000
|
) : null}
|
|
1362
|
-
{props.error ? (
|
|
2001
|
+
{props.error && props.supportsLiveFrames ? (
|
|
1363
2002
|
<button
|
|
1364
2003
|
type="button"
|
|
1365
2004
|
onClick={props.onReconnect}
|
|
@@ -1380,21 +2019,30 @@ function BrowserStatusBar(props: {
|
|
|
1380
2019
|
observation: ReturnType<typeof useBrowserSession>["observation"];
|
|
1381
2020
|
connectionState: string;
|
|
1382
2021
|
refreshing: boolean;
|
|
2022
|
+
diagnosticsOpen: boolean;
|
|
1383
2023
|
onOpenComputer?: (() => void) | undefined;
|
|
1384
2024
|
onDiagnostics: () => void;
|
|
1385
2025
|
}) {
|
|
1386
|
-
const errors =
|
|
2026
|
+
const errors =
|
|
2027
|
+
(props.observation?.diagnostics.consoleErrorCount ?? 0) +
|
|
2028
|
+
(props.observation?.diagnostics.pageErrorCount ?? 0);
|
|
1387
2029
|
const failed = props.observation?.diagnostics.failedRequestCount ?? 0;
|
|
1388
2030
|
return (
|
|
1389
2031
|
<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
2032
|
<span
|
|
1391
2033
|
className={cn(
|
|
1392
2034
|
"size-1.5 rounded-full",
|
|
1393
|
-
props.connectionState === "live"
|
|
2035
|
+
props.connectionState === "live" || props.connectionState === "semantic"
|
|
2036
|
+
? "bg-og-status-running"
|
|
2037
|
+
: "bg-og-muted",
|
|
1394
2038
|
)}
|
|
1395
2039
|
/>
|
|
1396
2040
|
<span>
|
|
1397
|
-
{props.connectionState === "live"
|
|
2041
|
+
{props.connectionState === "live"
|
|
2042
|
+
? "Live"
|
|
2043
|
+
: props.connectionState === "semantic"
|
|
2044
|
+
? "Semantic"
|
|
2045
|
+
: browserConnectionLabel(props.connectionState)}
|
|
1398
2046
|
</span>
|
|
1399
2047
|
<span>{props.profile?.name ?? "Temporary browser"}</span>
|
|
1400
2048
|
<span className="min-w-0 flex-1 truncate">{props.target?.title}</span>
|
|
@@ -1413,7 +2061,12 @@ function BrowserStatusBar(props: {
|
|
|
1413
2061
|
<button
|
|
1414
2062
|
type="button"
|
|
1415
2063
|
onClick={props.onDiagnostics}
|
|
1416
|
-
|
|
2064
|
+
aria-expanded={props.diagnosticsOpen}
|
|
2065
|
+
aria-controls="browser-diagnostics-drawer"
|
|
2066
|
+
className={cn(
|
|
2067
|
+
"flex items-center gap-1 rounded px-1.5 py-0.5 transition hover:bg-og-surface-2 hover:text-og-fg",
|
|
2068
|
+
props.diagnosticsOpen && "bg-og-surface-2 text-og-fg",
|
|
2069
|
+
)}
|
|
1417
2070
|
>
|
|
1418
2071
|
<BugIcon className="size-3" />
|
|
1419
2072
|
{errors + failed > 0 ? errors + failed : "Debug"}
|
|
@@ -1422,6 +2075,463 @@ function BrowserStatusBar(props: {
|
|
|
1422
2075
|
);
|
|
1423
2076
|
}
|
|
1424
2077
|
|
|
2078
|
+
function BrowserDiagnosticsDrawer(props: {
|
|
2079
|
+
session: BrowserSession | null;
|
|
2080
|
+
profile: BrowserIdentity | null;
|
|
2081
|
+
target: BrowserTarget | null;
|
|
2082
|
+
observation: BrowserObservation | null;
|
|
2083
|
+
baseRevisionOrdinal: number | null;
|
|
2084
|
+
state: BrowserDiagnosticsView;
|
|
2085
|
+
downloads: BrowserDownload[];
|
|
2086
|
+
downloadsEnabled: boolean;
|
|
2087
|
+
downloadsLoading: boolean;
|
|
2088
|
+
downloadsRefreshing: boolean;
|
|
2089
|
+
downloadsError: Error | null;
|
|
2090
|
+
savingDownloadIds: string[];
|
|
2091
|
+
onSaveDownload: (
|
|
2092
|
+
downloadId: string,
|
|
2093
|
+
destinationPath: string,
|
|
2094
|
+
overwrite: boolean,
|
|
2095
|
+
) => Promise<BrowserDownloadSaveResponse>;
|
|
2096
|
+
onRefreshDownloads: () => void;
|
|
2097
|
+
onRefresh: () => void;
|
|
2098
|
+
onClose: () => void;
|
|
2099
|
+
}) {
|
|
2100
|
+
const session = props.session;
|
|
2101
|
+
const diagnostics = props.observation?.diagnostics;
|
|
2102
|
+
const semanticMode = props.observation?.semantic
|
|
2103
|
+
? "Semantic page structure available"
|
|
2104
|
+
: props.observation?.screenshot
|
|
2105
|
+
? "Visual fallback in use"
|
|
2106
|
+
: "Waiting for page observation";
|
|
2107
|
+
return (
|
|
2108
|
+
<aside
|
|
2109
|
+
id="browser-diagnostics-drawer"
|
|
2110
|
+
role="region"
|
|
2111
|
+
aria-label="Browser diagnostics"
|
|
2112
|
+
className="absolute bottom-7 right-0 top-10 z-40 flex w-full max-w-md flex-col border-l border-og-border bg-og-surface-0 shadow-2xl"
|
|
2113
|
+
>
|
|
2114
|
+
<div className="flex h-11 shrink-0 items-center gap-2 border-b border-og-border px-3">
|
|
2115
|
+
<BugIcon className="size-4 text-og-muted" />
|
|
2116
|
+
<div className="min-w-0 flex-1">
|
|
2117
|
+
<p className="text-og-control font-semibold text-og-fg">Browser diagnostics</p>
|
|
2118
|
+
<p className="truncate text-og-xs text-og-subtle">
|
|
2119
|
+
{props.target?.title || shortUrl(props.target?.url ?? "") || "Current tab"}
|
|
2120
|
+
</p>
|
|
2121
|
+
</div>
|
|
2122
|
+
<button
|
|
2123
|
+
type="button"
|
|
2124
|
+
onClick={props.onRefresh}
|
|
2125
|
+
disabled={props.state.loading}
|
|
2126
|
+
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"
|
|
2127
|
+
aria-label="Refresh browser diagnostics"
|
|
2128
|
+
>
|
|
2129
|
+
<RefreshCwIcon className={cn("size-3.5", props.state.loading && "animate-spin")} />
|
|
2130
|
+
</button>
|
|
2131
|
+
<button
|
|
2132
|
+
type="button"
|
|
2133
|
+
onClick={props.onClose}
|
|
2134
|
+
className="grid size-7 place-items-center rounded-og-sm text-og-muted transition hover:bg-og-surface-2 hover:text-og-fg"
|
|
2135
|
+
aria-label="Close browser diagnostics"
|
|
2136
|
+
>
|
|
2137
|
+
<XIcon className="size-3.5" />
|
|
2138
|
+
</button>
|
|
2139
|
+
</div>
|
|
2140
|
+
|
|
2141
|
+
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
2142
|
+
<section className="border-b border-og-border p-3" aria-labelledby="browser-runtime-title">
|
|
2143
|
+
<h3
|
|
2144
|
+
id="browser-runtime-title"
|
|
2145
|
+
className="text-[10px] font-semibold uppercase tracking-[0.14em] text-og-subtle"
|
|
2146
|
+
>
|
|
2147
|
+
Runtime
|
|
2148
|
+
</h3>
|
|
2149
|
+
<dl className="mt-2 grid grid-cols-[7rem_minmax(0,1fr)] gap-x-3 gap-y-1.5 text-og-xs">
|
|
2150
|
+
<DiagnosticFact label="Engine" value={browserEngineLabel(session)} />
|
|
2151
|
+
<DiagnosticFact label="Driver" value={session?.driverId ?? "Unavailable"} />
|
|
2152
|
+
<DiagnosticFact label="Placement" value={placementLabel(session ?? undefined)} />
|
|
2153
|
+
<DiagnosticFact
|
|
2154
|
+
label="Profile"
|
|
2155
|
+
value={
|
|
2156
|
+
props.profile
|
|
2157
|
+
? `${props.profile.name}${props.baseRevisionOrdinal ? ` · v${props.baseRevisionOrdinal}` : ""}`
|
|
2158
|
+
: "Temporary"
|
|
2159
|
+
}
|
|
2160
|
+
/>
|
|
2161
|
+
<DiagnosticFact
|
|
2162
|
+
label="Controller"
|
|
2163
|
+
value={shortGeneration(session?.controller?.controllerGeneration)}
|
|
2164
|
+
/>
|
|
2165
|
+
<DiagnosticFact
|
|
2166
|
+
label="Network"
|
|
2167
|
+
value={session?.networkRouteId ? "Custom route" : "Placement default"}
|
|
2168
|
+
/>
|
|
2169
|
+
<DiagnosticFact label="Observation" value={semanticMode} />
|
|
2170
|
+
</dl>
|
|
2171
|
+
</section>
|
|
2172
|
+
|
|
2173
|
+
<section className="border-b border-og-border p-3" aria-labelledby="browser-page-title">
|
|
2174
|
+
<h3
|
|
2175
|
+
id="browser-page-title"
|
|
2176
|
+
className="text-[10px] font-semibold uppercase tracking-[0.14em] text-og-subtle"
|
|
2177
|
+
>
|
|
2178
|
+
Current page
|
|
2179
|
+
</h3>
|
|
2180
|
+
<dl className="mt-2 grid grid-cols-2 gap-2">
|
|
2181
|
+
<DiagnosticCount label="Console errors" value={diagnostics?.consoleErrorCount ?? 0} />
|
|
2182
|
+
<DiagnosticCount label="Page errors" value={diagnostics?.pageErrorCount ?? 0} />
|
|
2183
|
+
<DiagnosticCount label="Failed requests" value={diagnostics?.failedRequestCount ?? 0} />
|
|
2184
|
+
<DiagnosticCount label="Downloads" value={diagnostics?.downloadCount ?? 0} />
|
|
2185
|
+
</dl>
|
|
2186
|
+
{session?.failureCode ? (
|
|
2187
|
+
<p className="mt-2 rounded-og-sm border border-og-status-error/30 bg-og-status-error/5 px-2.5 py-2 text-og-xs text-og-status-error">
|
|
2188
|
+
Browser state: {session.failureCode}
|
|
2189
|
+
</p>
|
|
2190
|
+
) : null}
|
|
2191
|
+
</section>
|
|
2192
|
+
|
|
2193
|
+
{props.downloadsEnabled ? (
|
|
2194
|
+
<BrowserDownloadsPanel
|
|
2195
|
+
downloads={props.downloads}
|
|
2196
|
+
loading={props.downloadsLoading}
|
|
2197
|
+
refreshing={props.downloadsRefreshing}
|
|
2198
|
+
error={props.downloadsError}
|
|
2199
|
+
savingDownloadIds={props.savingDownloadIds}
|
|
2200
|
+
onSave={props.onSaveDownload}
|
|
2201
|
+
onRefresh={props.onRefreshDownloads}
|
|
2202
|
+
/>
|
|
2203
|
+
) : null}
|
|
2204
|
+
|
|
2205
|
+
<section className="p-3" aria-labelledby="browser-events-title">
|
|
2206
|
+
<div className="flex items-center justify-between gap-2">
|
|
2207
|
+
<h3
|
|
2208
|
+
id="browser-events-title"
|
|
2209
|
+
className="text-[10px] font-semibold uppercase tracking-[0.14em] text-og-subtle"
|
|
2210
|
+
>
|
|
2211
|
+
Recent events
|
|
2212
|
+
</h3>
|
|
2213
|
+
{props.state.batch?.truncated ? (
|
|
2214
|
+
<span className="text-[10px] text-og-subtle">Newest 100</span>
|
|
2215
|
+
) : null}
|
|
2216
|
+
</div>
|
|
2217
|
+
{props.state.loading && !props.state.batch ? (
|
|
2218
|
+
<div className="flex items-center gap-2 py-6 text-og-control text-og-muted">
|
|
2219
|
+
<LoaderCircleIcon className="size-3.5 animate-spin" /> Loading diagnostics…
|
|
2220
|
+
</div>
|
|
2221
|
+
) : props.state.error ? (
|
|
2222
|
+
<div className="py-5">
|
|
2223
|
+
<p className="text-og-control text-og-status-error">{props.state.error.message}</p>
|
|
2224
|
+
<button
|
|
2225
|
+
type="button"
|
|
2226
|
+
onClick={props.onRefresh}
|
|
2227
|
+
className="mt-2 text-og-control font-medium text-og-accent hover:underline"
|
|
2228
|
+
>
|
|
2229
|
+
Try again
|
|
2230
|
+
</button>
|
|
2231
|
+
</div>
|
|
2232
|
+
) : props.state.batch?.entries.length ? (
|
|
2233
|
+
<ol className="mt-2 space-y-2">
|
|
2234
|
+
{props.state.batch.entries.map((entry) => (
|
|
2235
|
+
<li
|
|
2236
|
+
key={entry.sequence}
|
|
2237
|
+
className="rounded-og-sm border border-og-border bg-og-bg p-2.5"
|
|
2238
|
+
>
|
|
2239
|
+
<div className="flex items-center gap-2 text-[10px] text-og-subtle">
|
|
2240
|
+
<span
|
|
2241
|
+
className={cn(
|
|
2242
|
+
"font-semibold uppercase tracking-wide",
|
|
2243
|
+
diagnosticTone(entry.level),
|
|
2244
|
+
)}
|
|
2245
|
+
>
|
|
2246
|
+
{diagnosticKindLabel(entry.kind)}
|
|
2247
|
+
</span>
|
|
2248
|
+
<span className="ml-auto">{formatDiagnosticTime(entry.occurredAt)}</span>
|
|
2249
|
+
</div>
|
|
2250
|
+
<p className="mt-1 whitespace-pre-wrap break-words text-og-xs leading-5 text-og-fg">
|
|
2251
|
+
{entry.message}
|
|
2252
|
+
</p>
|
|
2253
|
+
{entry.url || entry.filename || entry.method || entry.status ? (
|
|
2254
|
+
<p className="mt-1 truncate font-og-mono text-[10px] text-og-subtle">
|
|
2255
|
+
{[entry.method, entry.status, entry.filename, entry.url]
|
|
2256
|
+
.filter((value) => value !== null)
|
|
2257
|
+
.join(" · ")}
|
|
2258
|
+
</p>
|
|
2259
|
+
) : null}
|
|
2260
|
+
</li>
|
|
2261
|
+
))}
|
|
2262
|
+
</ol>
|
|
2263
|
+
) : (
|
|
2264
|
+
<p className="py-6 text-og-control text-og-muted">No diagnostics for this tab.</p>
|
|
2265
|
+
)}
|
|
2266
|
+
</section>
|
|
2267
|
+
</div>
|
|
2268
|
+
</aside>
|
|
2269
|
+
);
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
function BrowserDownloadsPanel(props: {
|
|
2273
|
+
downloads: BrowserDownload[];
|
|
2274
|
+
loading: boolean;
|
|
2275
|
+
refreshing: boolean;
|
|
2276
|
+
error: Error | null;
|
|
2277
|
+
savingDownloadIds: string[];
|
|
2278
|
+
onSave: (
|
|
2279
|
+
downloadId: string,
|
|
2280
|
+
destinationPath: string,
|
|
2281
|
+
overwrite: boolean,
|
|
2282
|
+
) => Promise<BrowserDownloadSaveResponse>;
|
|
2283
|
+
onRefresh: () => void;
|
|
2284
|
+
}) {
|
|
2285
|
+
const [paths, setPaths] = useState<Record<string, string>>({});
|
|
2286
|
+
const [overwrite, setOverwrite] = useState<Record<string, boolean>>({});
|
|
2287
|
+
const [savedPaths, setSavedPaths] = useState<Record<string, string>>({});
|
|
2288
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
2289
|
+
const saving = useMemo(() => new Set(props.savingDownloadIds), [props.savingDownloadIds]);
|
|
2290
|
+
|
|
2291
|
+
const save = (download: BrowserDownload) => {
|
|
2292
|
+
const destinationPath = (paths[download.id] ?? defaultDownloadDestination(download)).trim();
|
|
2293
|
+
if (!destinationPath || saving.has(download.id)) return;
|
|
2294
|
+
setErrors((current) => ({ ...current, [download.id]: "" }));
|
|
2295
|
+
void props
|
|
2296
|
+
.onSave(download.id, destinationPath, overwrite[download.id] ?? false)
|
|
2297
|
+
.then((response) => {
|
|
2298
|
+
setSavedPaths((current) => ({ ...current, [download.id]: response.destinationPath }));
|
|
2299
|
+
})
|
|
2300
|
+
.catch((cause) => {
|
|
2301
|
+
setErrors((current) => ({
|
|
2302
|
+
...current,
|
|
2303
|
+
[download.id]: cause instanceof Error ? cause.message : "Could not save this download.",
|
|
2304
|
+
}));
|
|
2305
|
+
});
|
|
2306
|
+
};
|
|
2307
|
+
|
|
2308
|
+
return (
|
|
2309
|
+
<section className="border-b border-og-border p-3" aria-labelledby="browser-downloads-title">
|
|
2310
|
+
<div className="flex items-center gap-2">
|
|
2311
|
+
<DownloadIcon className="size-3.5 text-og-muted" />
|
|
2312
|
+
<h3
|
|
2313
|
+
id="browser-downloads-title"
|
|
2314
|
+
className="text-[10px] font-semibold uppercase tracking-[0.14em] text-og-subtle"
|
|
2315
|
+
>
|
|
2316
|
+
Downloads
|
|
2317
|
+
</h3>
|
|
2318
|
+
{props.refreshing ? (
|
|
2319
|
+
<LoaderCircleIcon className="ml-auto size-3 animate-spin text-og-muted" />
|
|
2320
|
+
) : null}
|
|
2321
|
+
</div>
|
|
2322
|
+
|
|
2323
|
+
{props.loading ? (
|
|
2324
|
+
<div className="flex items-center gap-2 py-5 text-og-control text-og-muted">
|
|
2325
|
+
<LoaderCircleIcon className="size-3.5 animate-spin" /> Loading downloads…
|
|
2326
|
+
</div>
|
|
2327
|
+
) : props.error ? (
|
|
2328
|
+
<div className="py-4">
|
|
2329
|
+
<p className="text-og-control text-og-status-error">{props.error.message}</p>
|
|
2330
|
+
<button
|
|
2331
|
+
type="button"
|
|
2332
|
+
onClick={props.onRefresh}
|
|
2333
|
+
className="mt-2 text-og-control font-medium text-og-accent hover:underline"
|
|
2334
|
+
>
|
|
2335
|
+
Try again
|
|
2336
|
+
</button>
|
|
2337
|
+
</div>
|
|
2338
|
+
) : props.downloads.length === 0 ? (
|
|
2339
|
+
<p className="py-4 text-og-control text-og-muted">No files downloaded yet.</p>
|
|
2340
|
+
) : (
|
|
2341
|
+
<ol className="mt-2 space-y-2">
|
|
2342
|
+
{props.downloads.map((download) => {
|
|
2343
|
+
const destinationPath = paths[download.id] ?? defaultDownloadDestination(download);
|
|
2344
|
+
const isSaving = saving.has(download.id);
|
|
2345
|
+
const isSaved = savedPaths[download.id] === destinationPath;
|
|
2346
|
+
return (
|
|
2347
|
+
<li
|
|
2348
|
+
key={download.id}
|
|
2349
|
+
className="rounded-og-sm border border-og-border bg-og-bg p-2.5"
|
|
2350
|
+
>
|
|
2351
|
+
<div className="flex min-w-0 items-start gap-2">
|
|
2352
|
+
<span className="mt-0.5 grid size-6 shrink-0 place-items-center rounded bg-og-surface-2 text-og-muted">
|
|
2353
|
+
{isSaved ? (
|
|
2354
|
+
<FileCheck2Icon className="size-3.5 text-og-status-success" />
|
|
2355
|
+
) : (
|
|
2356
|
+
<DownloadIcon className="size-3.5" />
|
|
2357
|
+
)}
|
|
2358
|
+
</span>
|
|
2359
|
+
<div className="min-w-0 flex-1">
|
|
2360
|
+
<p
|
|
2361
|
+
className="truncate text-og-xs font-medium text-og-fg"
|
|
2362
|
+
title={download.filename}
|
|
2363
|
+
>
|
|
2364
|
+
{download.filename}
|
|
2365
|
+
</p>
|
|
2366
|
+
<p className="mt-0.5 text-[10px] text-og-subtle">
|
|
2367
|
+
{downloadStatusLabel(download)}
|
|
2368
|
+
</p>
|
|
2369
|
+
</div>
|
|
2370
|
+
</div>
|
|
2371
|
+
|
|
2372
|
+
{download.status === "completed" ? (
|
|
2373
|
+
<div className="mt-2">
|
|
2374
|
+
<div className="flex gap-1.5">
|
|
2375
|
+
<input
|
|
2376
|
+
type="text"
|
|
2377
|
+
value={destinationPath}
|
|
2378
|
+
onInput={(event) => {
|
|
2379
|
+
const value = event.currentTarget.value;
|
|
2380
|
+
setPaths((current) => ({ ...current, [download.id]: value }));
|
|
2381
|
+
setErrors((current) => ({ ...current, [download.id]: "" }));
|
|
2382
|
+
}}
|
|
2383
|
+
onKeyDown={(event) => {
|
|
2384
|
+
if (event.key === "Enter") save(download);
|
|
2385
|
+
}}
|
|
2386
|
+
disabled={isSaving}
|
|
2387
|
+
aria-label={`Workspace path for ${download.filename}`}
|
|
2388
|
+
className="h-7 min-w-0 flex-1 rounded-og-sm border border-og-border bg-og-surface-0 px-2 font-og-mono text-[10px] text-og-fg transition focus:border-og-accent focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent disabled:opacity-50"
|
|
2389
|
+
/>
|
|
2390
|
+
<button
|
|
2391
|
+
type="button"
|
|
2392
|
+
onClick={() => save(download)}
|
|
2393
|
+
disabled={
|
|
2394
|
+
isSaving ||
|
|
2395
|
+
!destinationPath.trim() ||
|
|
2396
|
+
(isSaved && !overwrite[download.id])
|
|
2397
|
+
}
|
|
2398
|
+
className="inline-flex h-7 shrink-0 items-center gap-1 rounded-og-sm border border-og-border bg-og-surface-1 px-2 text-[10px] font-medium text-og-fg transition hover:bg-og-surface-2 disabled:opacity-40"
|
|
2399
|
+
>
|
|
2400
|
+
{isSaving ? (
|
|
2401
|
+
<LoaderCircleIcon className="size-3 animate-spin" />
|
|
2402
|
+
) : (
|
|
2403
|
+
<SaveIcon className="size-3" />
|
|
2404
|
+
)}
|
|
2405
|
+
{isSaving
|
|
2406
|
+
? "Saving"
|
|
2407
|
+
: isSaved
|
|
2408
|
+
? "Saved"
|
|
2409
|
+
: overwrite[download.id]
|
|
2410
|
+
? "Replace"
|
|
2411
|
+
: "Save"}
|
|
2412
|
+
</button>
|
|
2413
|
+
</div>
|
|
2414
|
+
<label className="mt-1.5 flex w-fit items-center gap-1.5 text-[10px] text-og-subtle">
|
|
2415
|
+
<input
|
|
2416
|
+
type="checkbox"
|
|
2417
|
+
checked={overwrite[download.id] ?? false}
|
|
2418
|
+
onChange={(event) => {
|
|
2419
|
+
setOverwrite((current) => ({
|
|
2420
|
+
...current,
|
|
2421
|
+
[download.id]: event.currentTarget.checked,
|
|
2422
|
+
}));
|
|
2423
|
+
setErrors((current) => ({ ...current, [download.id]: "" }));
|
|
2424
|
+
}}
|
|
2425
|
+
disabled={isSaving}
|
|
2426
|
+
className="size-3 accent-og-accent"
|
|
2427
|
+
/>
|
|
2428
|
+
Replace an existing workspace file
|
|
2429
|
+
</label>
|
|
2430
|
+
{errors[download.id] ? (
|
|
2431
|
+
<p className="mt-1.5 break-words text-[10px] text-og-status-error">
|
|
2432
|
+
{errors[download.id]}
|
|
2433
|
+
</p>
|
|
2434
|
+
) : null}
|
|
2435
|
+
</div>
|
|
2436
|
+
) : null}
|
|
2437
|
+
</li>
|
|
2438
|
+
);
|
|
2439
|
+
})}
|
|
2440
|
+
</ol>
|
|
2441
|
+
)}
|
|
2442
|
+
</section>
|
|
2443
|
+
);
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
function downloadStatusLabel(download: BrowserDownload): string {
|
|
2447
|
+
if (download.status === "completed") return formatBytes(download.receivedBytes);
|
|
2448
|
+
if (download.status === "in_progress") {
|
|
2449
|
+
const received = formatBytes(download.receivedBytes);
|
|
2450
|
+
return download.totalBytes === null
|
|
2451
|
+
? `Downloading · ${received}`
|
|
2452
|
+
: `Downloading · ${received} of ${formatBytes(download.totalBytes)}`;
|
|
2453
|
+
}
|
|
2454
|
+
if (download.status === "cancelled") return "Cancelled";
|
|
2455
|
+
if (download.status === "unavailable") return "No longer available";
|
|
2456
|
+
return "Download failed";
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
function defaultDownloadDestination(download: BrowserDownload): string {
|
|
2460
|
+
const leaf = download.filename.split(/[\\/]/u).at(-1)?.trim() ?? "";
|
|
2461
|
+
const portable = leaf
|
|
2462
|
+
.normalize("NFC")
|
|
2463
|
+
.replace(/[<>:"|?*\u0000-\u001f\u007f]/gu, "-")
|
|
2464
|
+
.replace(/[ .]+$/u, "");
|
|
2465
|
+
if (portable.length === 0 || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(portable)) {
|
|
2466
|
+
return `download-${download.id.slice(0, 8)}`;
|
|
2467
|
+
}
|
|
2468
|
+
return portable;
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
function DiagnosticFact(props: { label: string; value: string }) {
|
|
2472
|
+
return (
|
|
2473
|
+
<>
|
|
2474
|
+
<dt className="text-og-subtle">{props.label}</dt>
|
|
2475
|
+
<dd className="min-w-0 truncate text-og-fg" title={props.value}>
|
|
2476
|
+
{props.value}
|
|
2477
|
+
</dd>
|
|
2478
|
+
</>
|
|
2479
|
+
);
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
function DiagnosticCount(props: { label: string; value: number }) {
|
|
2483
|
+
return (
|
|
2484
|
+
<div className="rounded-og-sm border border-og-border bg-og-bg px-2.5 py-2">
|
|
2485
|
+
<dt className="text-[10px] text-og-subtle">{props.label}</dt>
|
|
2486
|
+
<dd
|
|
2487
|
+
className={cn(
|
|
2488
|
+
"mt-0.5 text-og-sm font-semibold",
|
|
2489
|
+
props.value ? "text-og-fg" : "text-og-muted",
|
|
2490
|
+
)}
|
|
2491
|
+
>
|
|
2492
|
+
{props.value}
|
|
2493
|
+
</dd>
|
|
2494
|
+
</div>
|
|
2495
|
+
);
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
function browserEngineLabel(session: BrowserSession | null): string {
|
|
2499
|
+
if (!session) return "Unavailable";
|
|
2500
|
+
const version = session.engineVersion ? ` ${session.engineVersion}` : "";
|
|
2501
|
+
return `${session.engine}${version} · ${session.headless ? "headless" : "headed"}`;
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
function shortGeneration(value: string | null | undefined): string {
|
|
2505
|
+
if (!value) return "Unavailable";
|
|
2506
|
+
return value.length > 16 ? `${value.slice(0, 8)}…${value.slice(-6)}` : value;
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
function diagnosticKindLabel(kind: BrowserDiagnosticBatch["entries"][number]["kind"]): string {
|
|
2510
|
+
switch (kind) {
|
|
2511
|
+
case "console":
|
|
2512
|
+
return "Console";
|
|
2513
|
+
case "page_error":
|
|
2514
|
+
return "Page error";
|
|
2515
|
+
case "failed_request":
|
|
2516
|
+
return "Request";
|
|
2517
|
+
case "download":
|
|
2518
|
+
return "Download";
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
function diagnosticTone(level: BrowserDiagnosticBatch["entries"][number]["level"]): string {
|
|
2523
|
+
if (level === "error") return "text-og-status-error";
|
|
2524
|
+
if (level === "warning") return "text-og-status-waiting";
|
|
2525
|
+
return "text-og-muted";
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
function formatDiagnosticTime(value: string): string {
|
|
2529
|
+
const date = new Date(value);
|
|
2530
|
+
return Number.isNaN(date.getTime())
|
|
2531
|
+
? ""
|
|
2532
|
+
: date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
2533
|
+
}
|
|
2534
|
+
|
|
1425
2535
|
function BrowserNotice(props: { icon: ReactNode; text: string; className?: string }) {
|
|
1426
2536
|
return (
|
|
1427
2537
|
<div
|
|
@@ -1477,41 +2587,6 @@ function browserPoint(
|
|
|
1477
2587
|
};
|
|
1478
2588
|
}
|
|
1479
2589
|
|
|
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
2590
|
function semanticNodes(roots: readonly InteractionSemanticNode[]): InteractionSemanticNode[] {
|
|
1516
2591
|
const result: InteractionSemanticNode[] = [];
|
|
1517
2592
|
const stack = [...roots].reverse();
|
|
@@ -1523,19 +2598,24 @@ function semanticNodes(roots: readonly InteractionSemanticNode[]): InteractionSe
|
|
|
1523
2598
|
return result;
|
|
1524
2599
|
}
|
|
1525
2600
|
|
|
1526
|
-
function
|
|
2601
|
+
function frameMatchesSelectedTarget(
|
|
1527
2602
|
frame: BrowserFrame | null,
|
|
1528
|
-
|
|
2603
|
+
session: BrowserSession | null,
|
|
2604
|
+
target: BrowserTarget | null,
|
|
1529
2605
|
): frame is BrowserFrame {
|
|
2606
|
+
// The stream hook already validates the frame against the authenticated
|
|
2607
|
+
// attachment's controller generation. The separately-polled BrowserSession
|
|
2608
|
+
// can briefly carry the previous generation while an attachment is issued;
|
|
2609
|
+
// comparing against it here discarded valid frames and left the viewer on
|
|
2610
|
+
// "Connecting" indefinitely.
|
|
1530
2611
|
return Boolean(
|
|
1531
2612
|
frame &&
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
frame.
|
|
1535
|
-
frame.targetId ===
|
|
1536
|
-
frame.targetGeneration ===
|
|
1537
|
-
frame.documentGeneration ===
|
|
1538
|
-
frame.frameId === observation.frameId,
|
|
2613
|
+
session &&
|
|
2614
|
+
target &&
|
|
2615
|
+
frame.browserSessionId === session.id &&
|
|
2616
|
+
frame.targetId === target.id &&
|
|
2617
|
+
frame.targetGeneration === target.targetGeneration &&
|
|
2618
|
+
frame.documentGeneration === target.documentGeneration,
|
|
1539
2619
|
);
|
|
1540
2620
|
}
|
|
1541
2621
|
|
|
@@ -1550,10 +2630,38 @@ function sameFrameFence(left: BrowserFrame, right: BrowserFrame): boolean {
|
|
|
1550
2630
|
);
|
|
1551
2631
|
}
|
|
1552
2632
|
|
|
2633
|
+
function sameOptionalBrowserFrame(left: BrowserFrame | null, right: BrowserFrame | null): boolean {
|
|
2634
|
+
return left === null || right === null ? left === right : sameFrameFence(left, right);
|
|
2635
|
+
}
|
|
2636
|
+
|
|
1553
2637
|
function isLiveBrowser(session: BrowserSession): boolean {
|
|
1554
2638
|
return !["ending", "ended", "failed", "lost"].includes(session.lifecycle);
|
|
1555
2639
|
}
|
|
1556
2640
|
|
|
2641
|
+
function countInterventions(
|
|
2642
|
+
interventions: readonly InteractionIntervention[],
|
|
2643
|
+
): Map<string, number> {
|
|
2644
|
+
const counts = new Map<string, number>();
|
|
2645
|
+
for (const intervention of interventions) {
|
|
2646
|
+
counts.set(intervention.resourceId, (counts.get(intervention.resourceId) ?? 0) + 1);
|
|
2647
|
+
}
|
|
2648
|
+
return counts;
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
function interventionTitle(intervention: InteractionIntervention): string {
|
|
2652
|
+
switch (intervention.kind) {
|
|
2653
|
+
case "manual_login":
|
|
2654
|
+
return "Sign in needed";
|
|
2655
|
+
case "mfa":
|
|
2656
|
+
return "Verification needed";
|
|
2657
|
+
case "external_action":
|
|
2658
|
+
case "confirmation":
|
|
2659
|
+
return "Action needed";
|
|
2660
|
+
case "other":
|
|
2661
|
+
return "Browser needs your help";
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
|
|
1557
2665
|
function placementLabel(session: BrowserSession | undefined): string {
|
|
1558
2666
|
if (!session) return "";
|
|
1559
2667
|
switch (session.placement.kind) {
|
|
@@ -1578,6 +2686,8 @@ function browserConnectionLabel(state: string): string {
|
|
|
1578
2686
|
return "Reconnecting…";
|
|
1579
2687
|
case "error":
|
|
1580
2688
|
return "Disconnected";
|
|
2689
|
+
case "semantic":
|
|
2690
|
+
return "Semantic";
|
|
1581
2691
|
default:
|
|
1582
2692
|
return "Waiting for browser…";
|
|
1583
2693
|
}
|