@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.
Files changed (132) hide show
  1. package/README.md +51 -1
  2. package/dist/artifacts.d.ts +1 -1
  3. package/dist/artifacts.js +43 -14
  4. package/dist/artifacts.js.map +1 -1
  5. package/dist/{chunk-ROIRTM37.js → chunk-5UQPNLIO.js} +6 -6
  6. package/dist/{chunk-JR3QFY6B.js → chunk-6KLX2RH5.js} +49 -2
  7. package/dist/chunk-6KLX2RH5.js.map +1 -0
  8. package/dist/{chunk-FWS3KC3Z.js → chunk-B6WPZSTT.js} +104 -104
  9. package/dist/{chunk-FWS3KC3Z.js.map → chunk-B6WPZSTT.js.map} +1 -1
  10. package/dist/{chunk-HDBG7XGO.js → chunk-C5ZPSSS5.js} +50 -1
  11. package/dist/chunk-C5ZPSSS5.js.map +1 -0
  12. package/dist/{chunk-X4S56Y2H.js → chunk-HA46CYQK.js} +135 -11
  13. package/dist/chunk-HA46CYQK.js.map +1 -0
  14. package/dist/{chunk-724BW4TW.js → chunk-IGC6A2ZI.js} +2 -2
  15. package/dist/chunk-LLOLUHVG.js +5937 -0
  16. package/dist/chunk-LLOLUHVG.js.map +1 -0
  17. package/dist/{chunk-WYA65Y3F.js → chunk-MKPIAGVL.js} +13 -3
  18. package/dist/chunk-MKPIAGVL.js.map +1 -0
  19. package/dist/chunk-R3AZOAB5.js +933 -0
  20. package/dist/chunk-R3AZOAB5.js.map +1 -0
  21. package/dist/{chunk-23EJ676W.js → chunk-XBZWOM2K.js} +9 -4
  22. package/dist/chunk-XBZWOM2K.js.map +1 -0
  23. package/dist/{chunk-JAGDUCZQ.js → chunk-Y7OEDUJ5.js} +17 -13
  24. package/dist/chunk-Y7OEDUJ5.js.map +1 -0
  25. package/dist/client.d.ts +6 -4
  26. package/dist/components/artifacts/index.d.ts +1 -0
  27. package/dist/components/artifacts/published-html-artifact-frame.d.ts +14 -0
  28. package/dist/components/browser-input.d.ts +7 -0
  29. package/dist/components/browser-viewer.d.ts +5 -4
  30. package/dist/components/computer-viewer.d.ts +2 -1
  31. package/dist/components/desktop-viewer.d.ts +26 -2
  32. package/dist/components/interaction-intervention-banner.d.ts +11 -0
  33. package/dist/components/sandbox-terminal.d.ts +4 -1
  34. package/dist/components/sandbox-workspace.d.ts +3 -0
  35. package/dist/composer.js +6 -6
  36. package/dist/hooks/use-auth-runs.d.ts +32 -0
  37. package/dist/hooks/use-browser-downloads.d.ts +25 -0
  38. package/dist/hooks/use-browser-identities.d.ts +5 -1
  39. package/dist/hooks/use-browser-session.d.ts +2 -1
  40. package/dist/hooks/use-browser-sessions.d.ts +1 -1
  41. package/dist/hooks/use-channels.d.ts +26 -0
  42. package/dist/hooks/use-computer-frame-stream.d.ts +1 -1
  43. package/dist/hooks/use-computer-session.d.ts +2 -1
  44. package/dist/hooks/use-computer-sessions.d.ts +1 -1
  45. package/dist/hooks/use-desktop-stream.d.ts +4 -0
  46. package/dist/hooks/use-interaction-interventions.d.ts +27 -0
  47. package/dist/hooks/use-interaction-invalidation.d.ts +19 -0
  48. package/dist/hooks/use-network-routes.d.ts +25 -0
  49. package/dist/hooks/use-site-auth-connections.d.ts +25 -0
  50. package/dist/hooks/use-supergrok-accounts.d.ts +26 -0
  51. package/dist/hooks/use-terminal-stream.d.ts +3 -0
  52. package/dist/index.d.ts +4 -0
  53. package/dist/index.js +1220 -1545
  54. package/dist/index.js.map +1 -1
  55. package/dist/interaction.d.ts +13 -3
  56. package/dist/interaction.js +520 -6
  57. package/dist/interaction.js.map +1 -1
  58. package/dist/interactive-workbench-viewers-TOHBXJEE.js +15 -0
  59. package/dist/lib/host-platform.d.ts +5 -0
  60. package/dist/lib/relay-wire.d.ts +20 -1
  61. package/dist/machines.js +3 -3
  62. package/dist/model-policy.d.ts +1 -1
  63. package/dist/model-policy.js +1 -1
  64. package/dist/provider.d.ts +3 -2
  65. package/dist/realtime/realtime-control.d.ts +6 -2
  66. package/dist/realtime.js +138 -24
  67. package/dist/realtime.js.map +1 -1
  68. package/dist/session-context.d.ts +10 -2
  69. package/dist/session-ui.js +3 -3
  70. package/dist/session.js +11 -11
  71. package/dist/timeline/types.d.ts +5 -0
  72. package/package.json +2 -2
  73. package/src/artifacts.ts +3 -0
  74. package/src/client.ts +73 -3
  75. package/src/components/artifacts/index.ts +5 -0
  76. package/src/components/artifacts/published-html-artifact-frame.tsx +38 -0
  77. package/src/components/browser-input.ts +65 -0
  78. package/src/components/browser-viewer.tsx +1287 -177
  79. package/src/components/computer-viewer.tsx +611 -114
  80. package/src/components/desktop-viewer.tsx +114 -1
  81. package/src/components/interaction-intervention-banner.tsx +99 -0
  82. package/src/components/machines/machine-detail.tsx +1 -1
  83. package/src/components/model-policy-picker.tsx +15 -0
  84. package/src/components/sandbox-terminal.tsx +24 -7
  85. package/src/components/sandbox-workspace.tsx +42 -4
  86. package/src/components/workspace-dock.tsx +59 -5
  87. package/src/hooks/use-attached-browsers.ts +13 -11
  88. package/src/hooks/use-auth-runs.ts +278 -0
  89. package/src/hooks/use-browser-downloads.ts +218 -0
  90. package/src/hooks/use-browser-frame-stream.ts +168 -59
  91. package/src/hooks/use-browser-identities.ts +47 -6
  92. package/src/hooks/use-browser-session.ts +94 -27
  93. package/src/hooks/use-browser-sessions.ts +19 -14
  94. package/src/hooks/use-channels.ts +99 -0
  95. package/src/hooks/use-computer-frame-stream.ts +184 -61
  96. package/src/hooks/use-computer-session.ts +22 -2
  97. package/src/hooks/use-computer-sessions.ts +19 -13
  98. package/src/hooks/use-desktop-stream.ts +39 -7
  99. package/src/hooks/use-interaction-interventions.ts +242 -0
  100. package/src/hooks/use-interaction-invalidation.ts +66 -0
  101. package/src/hooks/use-machine-chip.ts +13 -3
  102. package/src/hooks/use-network-routes.ts +214 -0
  103. package/src/hooks/use-site-auth-connections.ts +226 -0
  104. package/src/hooks/use-supergrok-accounts.ts +81 -0
  105. package/src/hooks/use-terminal-stream.ts +288 -55
  106. package/src/index.ts +8 -0
  107. package/src/interaction.ts +30 -0
  108. package/src/lib/host-platform.ts +10 -0
  109. package/src/lib/relay-wire.ts +43 -4
  110. package/src/lib/terminal-capability.ts +1 -1
  111. package/src/model-policy.ts +19 -4
  112. package/src/provider.tsx +133 -1
  113. package/src/realtime/realtime-control.tsx +181 -26
  114. package/src/session-context.ts +66 -1
  115. package/src/timeline/activity-rail.tsx +6 -0
  116. package/src/timeline/projection.ts +58 -0
  117. package/src/timeline/types.ts +2 -0
  118. package/styles/compiled.css +64 -6
  119. package/dist/chunk-23EJ676W.js.map +0 -1
  120. package/dist/chunk-6BBWWOOU.js +0 -82
  121. package/dist/chunk-6BBWWOOU.js.map +0 -1
  122. package/dist/chunk-7I2KB3OK.js +0 -3895
  123. package/dist/chunk-7I2KB3OK.js.map +0 -1
  124. package/dist/chunk-HDBG7XGO.js.map +0 -1
  125. package/dist/chunk-JAGDUCZQ.js.map +0 -1
  126. package/dist/chunk-JR3QFY6B.js.map +0 -1
  127. package/dist/chunk-WYA65Y3F.js.map +0 -1
  128. package/dist/chunk-X4S56Y2H.js.map +0 -1
  129. package/dist/interactive-workbench-viewers-KHNOHWQE.js +0 -13
  130. /package/dist/{chunk-ROIRTM37.js.map → chunk-5UQPNLIO.js.map} +0 -0
  131. /package/dist/{chunk-724BW4TW.js.map → chunk-IGC6A2ZI.js.map} +0 -0
  132. /package/dist/{interactive-workbench-viewers-KHNOHWQE.js.map → interactive-workbench-viewers-TOHBXJEE.js.map} +0 -0
@@ -1,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 managed browser. Browser-only
65
- * embedders remain valid and create headless sessions instead. */
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
- | ((name: string) => Promise<Pick<ComputerSession, "id" | "placement">>)
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
- const preferred = relevant[0] ?? liveSessions[0] ?? null;
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: enabled && selection !== null && controllerReady && browser.selectedTarget !== null,
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 = frameMatchesObservation(frames.frame, browser.observation)
345
+ const displayedFrame = frameMatchesSelectedTarget(
346
+ frames.frame,
347
+ browser.session,
348
+ browser.selectedTarget,
349
+ )
237
350
  ? frames.frame
238
351
  : null;
239
- const displayConnectionState =
240
- frames.state === "live" && !displayedFrame ? "connecting" : frames.state;
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 || !revisionId) {
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
- response.revisions.find((revision) => revision.id === revisionId)?.ordinal ?? null,
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(() => undefined);
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
- ? profiles.identities.find((candidate) => candidate.id === choice.identityId)
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
- const linkedComputer =
292
- !device && createLinkedComputer
293
- ? await createLinkedComputer(`${browserName} computer`)
294
- : null;
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
- profiles.identities,
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
- identity = (await profiles.create({ name })).identity;
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 className={cn("flex h-full min-h-0 flex-col overflow-hidden bg-og-bg", className)}>
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
- {selectedRegistrySession && !controllerReady ? (
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={browser.mutating || savingProfile}
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
- onAction={(action, frame) =>
483
- (frame ? browser.actFromFrame(action, frame) : browser.act(action)).then(
484
- () => undefined,
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="Other agents"
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 (props.creating) event.preventDefault();
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
- props.creating && "pointer-events-none opacity-50",
1098
+ busy && "pointer-events-none opacity-50",
727
1099
  )}
728
1100
  aria-label="New browser"
729
1101
  >
730
- {props.creating ? (
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">Clean browser</span>
787
- <span className="block text-og-xs text-og-subtle">No saved profile</span>
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
- {props.identities.length > 0 ? (
1162
+ {activeIdentities.length > 0 ? (
791
1163
  <div className="mt-1 border-t border-og-border pt-1">
792
- {props.identities.map((identity) => (
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 = props.session?.capabilities.identityPublication ?? false;
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
- ? props.baseRevisionOrdinal
880
- ? `Started from version ${props.baseRevisionOrdinal}.`
881
- : "Not saved yet."
882
- : "This browser is not reusable yet."}
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-none focus-visible:ring-2 focus-visible:ring-og-accent"
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
- : "This browser cannot save reusable profile state."}
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
- <button
995
- type="button"
996
- onClick={() => props.onClose(target.id)}
997
- disabled={props.mutating}
998
- className="ml-1 grid size-4 shrink-0 place-items-center rounded opacity-0 transition hover:bg-og-surface-3 group-hover:opacity-100 focus:opacity-100 disabled:opacity-30"
999
- aria-label={`Close ${target.title || "tab"}`}
1000
- >
1001
- <XIcon className="size-3" />
1002
- </button>
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
- <button
1006
- type="button"
1007
- onClick={props.onOpen}
1008
- disabled={props.mutating}
1009
- className="mb-1 grid size-6 shrink-0 place-items-center rounded-og-sm text-og-muted transition hover:bg-og-surface-2 hover:text-og-fg disabled:opacity-40"
1010
- aria-label="New tab"
1011
- >
1012
- <PlusIcon className="size-3.5" />
1013
- </button>
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-none focus-visible:ring-2 focus-visible:ring-og-accent disabled:opacity-50"
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<void>;
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
- useEffect(() => {
1106
- const frame = props.frame;
1107
- const canvas = canvasRef.current;
1108
- if (!frame || !canvas) return;
1109
- let cancelled = false;
1110
- let objectUrl: string | null = null;
1606
+ const paintQueuedFrames = useCallback(() => {
1607
+ if (decodingFrameRef.current) return;
1608
+ decodingFrameRef.current = true;
1111
1609
  void (async () => {
1112
1610
  try {
1113
- const blob = new Blob([frame.data.slice().buffer], {
1114
- type: frame.mediaType,
1115
- });
1116
- if (typeof createImageBitmap === "function") {
1117
- const bitmap = await createImageBitmap(blob);
1118
- if (cancelled) {
1119
- bitmap.close();
1120
- return;
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
- objectUrl = URL.createObjectURL(blob);
1127
- const image = await loadImage(objectUrl);
1128
- if (!cancelled) paintCanvas(canvas, image, frame.width, frame.height);
1129
- } catch (cause) {
1130
- if (!cancelled) errorRef.current(cause);
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
- cancelled = true;
1135
- if (objectUrl) URL.revokeObjectURL(objectUrl);
1655
+ mountedRef.current = false;
1656
+ queuedFrameRef.current = null;
1136
1657
  };
1137
- }, [props.frame]);
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((action: BrowserAction, frame: BrowserFrame | null) => {
1148
- actionTailRef.current = actionTailRef.current
1149
- .catch(() => undefined)
1150
- .then(async () => await actionRef.current(action, frame))
1151
- .catch((cause) => errorRef.current(cause));
1152
- }, []);
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 || props.mutating) return;
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
- enqueue({ type: "pointer", action: "double_click", x: to.x, y: to.y }, start.frame);
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
- clickTimerRef.current = setTimeout(() => {
1209
- clickTimerRef.current = null;
1210
- lastClickRef.current = null;
1211
- enqueue({ type: "pointer", action: "click", x: to.x, y: to.y }, start.frame);
1212
- }, 280);
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 input = (value: string) => {
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
- enqueue({ type: "type", text: value }, props.frame);
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-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-og-accent",
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) => input(event.currentTarget.value)}
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 ? "Live view disconnected" : browserConnectionLabel(props.connectionState)}
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">Page controls remain available</p>
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 = props.observation?.diagnostics.consoleErrorCount ?? 0;
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" ? "bg-og-status-running" : "bg-og-muted",
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" ? "Live" : browserConnectionLabel(props.connectionState)}
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
- className="flex items-center gap-1 rounded px-1.5 py-0.5 transition hover:bg-og-surface-2 hover:text-og-fg"
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 frameMatchesObservation(
2601
+ function frameMatchesSelectedTarget(
1527
2602
  frame: BrowserFrame | null,
1528
- observation: BrowserObservation | null,
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
- observation &&
1533
- frame.browserSessionId === observation.browserSessionId &&
1534
- frame.controllerGeneration === observation.target.controllerGeneration &&
1535
- frame.targetId === observation.target.id &&
1536
- frame.targetGeneration === observation.target.targetGeneration &&
1537
- frame.documentGeneration === observation.target.documentGeneration &&
1538
- frame.frameId === observation.frameId,
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
  }