@omercnet/paseo-shared-browser 1.2.1 → 1.2.2-next.237.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 (170) hide show
  1. package/README.md +312 -25
  2. package/REMOTE_BROWSER_RESEARCH.md +395 -0
  3. package/STREAMING_DESIGN.md +191 -0
  4. package/client/browser-auto-capture-density.test.ts +102 -0
  5. package/client/browser-auto-capture-density.ts +30 -0
  6. package/client/browser-canvas-input.test.ts +198 -0
  7. package/client/browser-canvas-input.ts +202 -0
  8. package/client/browser-canvas-keyboard-web.test.ts +113 -0
  9. package/client/browser-canvas-keyboard-web.ts +230 -0
  10. package/client/browser-canvas-keyboard.test.ts +123 -0
  11. package/client/browser-canvas-keyboard.ts +126 -0
  12. package/client/browser-canvas-layout.test.ts +109 -0
  13. package/client/browser-canvas-layout.ts +51 -0
  14. package/client/browser-canvas-viewport-web.test.ts +77 -0
  15. package/client/browser-canvas-viewport-web.ts +63 -0
  16. package/client/browser-canvas-viewport.tsx +152 -0
  17. package/client/browser-capture-cadence.test.ts +9 -0
  18. package/client/browser-capture-cadence.ts +6 -0
  19. package/client/browser-capture-density-controls.tsx +75 -0
  20. package/client/browser-chrome.tsx +234 -0
  21. package/client/browser-client-presence.ts +184 -0
  22. package/client/browser-compose-controls.test.tsx +275 -0
  23. package/client/browser-compose-controls.tsx +174 -0
  24. package/client/browser-control-button.tsx +109 -0
  25. package/client/browser-control-dialog.tsx +71 -0
  26. package/client/browser-control-request.test.ts +27 -0
  27. package/client/browser-control-request.ts +57 -0
  28. package/client/browser-control-tooltip-web.ts +10 -0
  29. package/client/browser-dialog-body.tsx +22 -0
  30. package/client/browser-emulation-mode.test.ts +75 -0
  31. package/client/browser-emulation-mode.ts +98 -0
  32. package/client/browser-fill-viewport.test.ts +45 -0
  33. package/client/browser-fill-viewport.ts +28 -0
  34. package/client/browser-frame-buffer.test.ts +157 -0
  35. package/client/browser-frame-buffer.ts +148 -0
  36. package/client/browser-frame-image.tsx +62 -0
  37. package/client/browser-image-load.test.ts +23 -0
  38. package/client/browser-image-load.ts +26 -0
  39. package/client/browser-input-queue.test.ts +1201 -0
  40. package/client/browser-input-queue.ts +505 -0
  41. package/client/browser-menu-placement.test.ts +90 -0
  42. package/client/browser-menu-placement.ts +78 -0
  43. package/client/browser-panel-styles.ts +384 -0
  44. package/client/browser-quality-controls.test.ts +26 -0
  45. package/client/browser-quality-controls.tsx +139 -0
  46. package/client/browser-resolution-picker.tsx +189 -0
  47. package/client/browser-tabs.tsx +117 -0
  48. package/client/browser-toolbar-menu-web.test.ts +185 -0
  49. package/client/browser-toolbar-menu-web.ts +159 -0
  50. package/client/browser-toolbar-menu.tsx +255 -0
  51. package/client/browser-toolbar-submenu.tsx +93 -0
  52. package/client/browser-video-bytes.test.ts +44 -0
  53. package/client/browser-video-decoder.test.ts +473 -0
  54. package/client/browser-video-decoder.ts +311 -0
  55. package/client/browser-video-surface.tsx +21 -0
  56. package/client/browser-video-visibility.test.ts +45 -0
  57. package/client/browser-viewer-recovery.test.ts +111 -0
  58. package/client/browser-viewer-recovery.ts +78 -0
  59. package/client/browser.tsx +1642 -1569
  60. package/client/favorite-star.tsx +23 -0
  61. package/client/frame-lifecycle.ts +62 -59
  62. package/client/use-browser-auto-capture-density.test.ts +104 -0
  63. package/client/use-browser-auto-capture-density.ts +43 -0
  64. package/client/use-browser-canvas-input.ts +370 -0
  65. package/client/use-browser-capture-density.ts +87 -0
  66. package/client/use-browser-emulation-mode.ts +56 -0
  67. package/client/use-browser-fill-viewport.test.ts +108 -0
  68. package/client/use-browser-fill-viewport.ts +62 -0
  69. package/client/use-browser-frame-buffer.ts +66 -0
  70. package/client/use-browser-image-capture.ts +139 -0
  71. package/client/use-browser-scoped-mutation.test.ts +118 -0
  72. package/client/use-browser-scoped-mutation.ts +59 -0
  73. package/client/use-browser-video.test.ts +991 -0
  74. package/client/use-browser-video.ts +474 -0
  75. package/client/use-browser-viewer-recovery.ts +26 -0
  76. package/client/use-resolution-favorites.ts +90 -0
  77. package/client/web.test.ts +125 -0
  78. package/client/web.ts +405 -0
  79. package/index.client.tsx +0 -10
  80. package/index.server.ts +32 -0
  81. package/npm-shrinkwrap.json +1834 -0
  82. package/package.json +13 -8
  83. package/paseo-plugin.json +3 -1
  84. package/scripts/prepare-dependencies.mjs +1 -1
  85. package/scripts/prepare-runtime.mjs +1 -1
  86. package/server/agent-browser-runtime.ts +1598 -314
  87. package/server/browser-cursor.test.ts +64 -0
  88. package/server/browser-cursor.ts +69 -0
  89. package/server/browser-frame-context.ts +22 -0
  90. package/server/browser-frame-epoch.test.ts +95 -0
  91. package/server/browser-frame-rejection.ts +17 -0
  92. package/server/browser-gesture.ts +120 -0
  93. package/server/browser-operation-diagnostics.test.ts +79 -0
  94. package/server/browser-operation-diagnostics.ts +103 -0
  95. package/server/browser-policy.ts +1313 -151
  96. package/server/browser-video-input-authority.test.ts +208 -0
  97. package/server/browser-video-policy.test.ts +564 -0
  98. package/server/browser-video-socket.test.ts +368 -0
  99. package/server/browser.ts +151 -16
  100. package/server/capture-transport-policy.test.ts +105 -0
  101. package/server/capture-transport-policy.ts +154 -0
  102. package/server/capture-transport-runtime.test.ts +213 -0
  103. package/server/daemon-launch-chain.test.ts +70 -0
  104. package/server/daemon-shutdown.test.ts +289 -0
  105. package/server/discrete-input-freshness.test.ts +364 -0
  106. package/server/discrete-input-native-lifetime.test.ts +150 -0
  107. package/server/display-loss-recovery.test.ts +294 -0
  108. package/server/emulation-restoration.test.ts +262 -0
  109. package/server/input-cleanup.ts +23 -0
  110. package/server/input-generation.test.ts +31 -0
  111. package/server/input-generation.ts +36 -0
  112. package/server/jpeg-capture-demand.test.ts +76 -0
  113. package/server/jpeg-capture-demand.ts +98 -0
  114. package/server/jpeg-frame.ts +29 -0
  115. package/server/keyboard-input.test.ts +61 -0
  116. package/server/keyboard-input.ts +104 -0
  117. package/server/live-input-policy.test.ts +958 -0
  118. package/server/live-input-runtime.test.ts +732 -0
  119. package/server/live-input-supervisor.test.ts +147 -0
  120. package/server/mcp-entry.ts +44 -2
  121. package/server/native-video-backpressure.test.ts +228 -0
  122. package/server/native-video-buffer.test.ts +141 -0
  123. package/server/native-video-buffer.ts +114 -0
  124. package/server/native-video-capture.test.ts +599 -0
  125. package/server/native-video-capture.ts +497 -0
  126. package/server/native-video-extension-source.test.ts +201 -0
  127. package/server/native-video-extension-source.ts +364 -0
  128. package/server/native-video-extension.test.ts +41 -0
  129. package/server/native-video-extension.ts +83 -0
  130. package/server/native-video-packet.ts +9 -0
  131. package/server/native-video-settings.test.ts +245 -0
  132. package/server/navigation-budget.ts +8 -0
  133. package/server/navigation-commit.ts +125 -0
  134. package/server/navigation-policy.test.ts +153 -0
  135. package/server/process-identity.ts +58 -0
  136. package/server/reattach-held-input.test.ts +143 -0
  137. package/server/runtime-frame-reuse.test.ts +295 -0
  138. package/server/runtime-jpeg-demand.test.ts +200 -0
  139. package/server/runtime-jpeg-shutdown.test.ts +210 -0
  140. package/server/runtime-jpeg-source-order.test.ts +201 -0
  141. package/server/runtime-launch-environment.test.ts +111 -0
  142. package/server/runtime-navigation.test.ts +327 -0
  143. package/server/runtime-owner-display.test.ts +152 -0
  144. package/server/runtime-owner.ts +305 -31
  145. package/server/runtime-page-state.test.ts +249 -0
  146. package/server/runtime-protocol.ts +31 -1
  147. package/server/runtime-target-retention.test.ts +181 -0
  148. package/server/runtime-video-opt-in.test.ts +82 -0
  149. package/server/runtime-video.test.ts +444 -0
  150. package/server/screencast-source-time.ts +46 -0
  151. package/server/stale-wheel-admission.test.ts +136 -0
  152. package/server/supervisor-client.ts +28 -4
  153. package/server/supervisor-heartbeat.test.ts +80 -0
  154. package/server/supervisor.ts +553 -55
  155. package/server/video-source-recovery.test.ts +20 -0
  156. package/server/video-source-recovery.ts +64 -0
  157. package/server/viewer-capture-lifetime.ts +51 -0
  158. package/server/virtual-display.test.ts +184 -0
  159. package/server/virtual-display.ts +236 -0
  160. package/shared/browser-display-preferences.ts +46 -0
  161. package/shared/browser-video.ts +80 -0
  162. package/shared/browser.ts +286 -65
  163. package/shared/capture-density.ts +16 -0
  164. package/shared/capture-settings.ts +17 -0
  165. package/shared/device-presets.ts +90 -0
  166. package/shared/resolution-menu.test.ts +120 -0
  167. package/shared/resolution-menu.ts +72 -0
  168. package/shared/video-settings.test.ts +38 -0
  169. package/shared/video-settings.ts +34 -0
  170. package/shared/viewport-limits.ts +5 -0
@@ -1,26 +1,22 @@
1
+ /**
2
+ * Workspace browser panel orchestration: viewer/control ownership, current
3
+ * decoded receipts, media handoff and menu actions. Native/DOM forwarding and
4
+ * presentation models live in focused counterpart hooks; this module never
5
+ * replays published mutations or substitutes visible pixels for input authority.
6
+ */
7
+ import { type PluginWorkspacePanelProps, useRpc } from "@getpaseo/plugin/client";
8
+ import { Icon, Modal } from "@getpaseo/plugin/client/react-native";
9
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
1
10
  import {
2
- type PluginClientContext,
3
- type PluginWorkspacePanelProps,
4
- useRpc,
5
- } from "@getpaseo/plugin/client";
6
- import { Icon, Modal, ScrollView, TextInput } from "@getpaseo/plugin/client/react-native";
7
- import { useMutation, useQuery } from "@tanstack/react-query";
8
- import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
9
- import {
10
- ActivityIndicator,
11
- type GestureResponderEvent,
12
- Image,
13
- type LayoutChangeEvent,
14
- PanResponder,
15
- Pressable,
16
- type StyleProp,
17
- StyleSheet,
18
- Text,
19
- type TextInputProps,
20
- type TextStyle,
21
- View,
22
- type ViewStyle,
23
- } from "react-native";
11
+ type ReactNode,
12
+ useCallback,
13
+ useEffect,
14
+ useLayoutEffect,
15
+ useMemo,
16
+ useRef,
17
+ useState,
18
+ } from "react";
19
+ import { type LayoutChangeEvent, Text, useWindowDimensions, View } from "react-native";
24
20
  import {
25
21
  acquireControlRpc,
26
22
  applyDevicePresetRpc,
@@ -28,63 +24,88 @@ import {
28
24
  type BrowserFrame,
29
25
  type BrowserInputEvent,
30
26
  type BrowserState,
27
+ beginBrowserGestureRpc,
31
28
  captureBrowserRpc,
32
- containedRect,
33
- createImageSizeLoader,
29
+ closeBrowserRpc,
30
+ closeBrowserTabRpc,
31
+ createBrowserTabRpc,
34
32
  DEVICE_PRESETS,
35
33
  type DevicePresetId,
36
34
  detachBrowserRpc,
37
35
  didBrowserRuntimeRestart,
36
+ endBrowserGestureRpc,
38
37
  isBrowserStateCurrent,
39
- listOpenBrowserWorkspacesRpc,
38
+ listBrowserTabsRpc,
39
+ MAX_BROWSER_TABS,
40
40
  MAX_VIEWPORT,
41
41
  MIN_VIEWPORT,
42
42
  navigateBrowserRpc,
43
43
  releaseControlRpc,
44
+ reopenBrowserRpc,
44
45
  resizeBrowserRpc,
45
46
  sendBrowserInputRpc,
46
- toViewportPoint,
47
+ setCaptureDensityRpc,
48
+ updateBrowserGestureRpc,
47
49
  } from "../shared/browser";
48
- import { FrameLifecycle } from "./frame-lifecycle";
49
-
50
- const SPACE = {
51
- xxs: 2,
52
- xs: 4,
53
- sm: 8,
54
- md: 12,
55
- lg: 16,
56
- } as const;
57
- const RADIUS = { sm: 6, md: 8, lg: 10 } as const;
58
- const TYPE = { caption: 11, body: 13, title: 14 } as const;
59
- const DIMENSION = {
60
- control: 34,
61
- touch: 44,
62
- pad: 60,
63
- icon: 15,
64
- addressCompact: 120,
65
- addressRegular: 220,
66
- canvasCompact: 220,
67
- canvasRegular: 320,
68
- helperMax: 420,
69
- viewportField: 72,
70
- typeField: 180,
71
- screenRadius: 20,
72
- } as const;
73
- const DRAG_THRESHOLD = 6;
74
- const SCROLL_STEP = 520;
75
- const CAPTURE_INTERVAL_READY = 250;
76
- const CAPTURE_INTERVAL_WAITING = 1_500;
50
+ import type { BrowserFrameAuthority } from "../shared/browser-video";
51
+ import { groupResolutionPresets } from "../shared/resolution-menu";
52
+ import {
53
+ type CaptureDensityMode,
54
+ getAutomaticCaptureDensity,
55
+ } from "./browser-auto-capture-density";
56
+ import { type BrowserCanvasDisplayMode, getBrowserCanvasLayout } from "./browser-canvas-layout";
57
+ import { BrowserCanvasViewport } from "./browser-canvas-viewport";
58
+ import {
59
+ CanvasPlaceholder,
60
+ ChromeIconButton,
61
+ ControlButton,
62
+ ErrorNotice,
63
+ Field,
64
+ } from "./browser-chrome";
65
+ import { ComposeTextControls } from "./browser-compose-controls";
66
+ import { BrowserControlDialog } from "./browser-control-dialog";
67
+ import {
68
+ type ControlRequest,
69
+ type ControlRequestBasis,
70
+ controlRequestBasis,
71
+ isControlRequestCurrent,
72
+ } from "./browser-control-request";
73
+ import { BrowserDialogBody } from "./browser-dialog-body";
74
+ import { type EmulationSelection, matchingResolutionPresetId } from "./browser-emulation-mode";
75
+ import { getFillViewportResolution } from "./browser-fill-viewport";
76
+ import type { FrameCandidate } from "./browser-frame-buffer";
77
+ import { BrowserFrameImage } from "./browser-frame-image";
78
+ import { createStyles, DIMENSION, SPACE } from "./browser-panel-styles";
79
+ import { BrowserResolutionPicker } from "./browser-resolution-picker";
80
+ import { BrowserTabs } from "./browser-tabs";
81
+ import {
82
+ BrowserMenuHeading,
83
+ BrowserMenuItem,
84
+ BrowserMenuSeparator,
85
+ BrowserToolbarMenu,
86
+ } from "./browser-toolbar-menu";
87
+ import { BrowserVideoSurface } from "./browser-video-surface";
88
+ import { isBrowserClosedError, isExpiredBrowserViewerError } from "./browser-viewer-recovery";
89
+ import { createFrameLifecycle } from "./frame-lifecycle";
90
+ import { useBrowserAutoCaptureDensity } from "./use-browser-auto-capture-density";
91
+ import { useBrowserCanvasInput } from "./use-browser-canvas-input";
92
+ import { useBrowserCaptureDensity } from "./use-browser-capture-density";
93
+ import { useBrowserEmulationMode } from "./use-browser-emulation-mode";
94
+ import { useBrowserFillViewport } from "./use-browser-fill-viewport";
95
+ import { useBrowserFrameBuffer } from "./use-browser-frame-buffer";
96
+ import { useBrowserImageCapture } from "./use-browser-image-capture";
97
+ import { useBrowserScopedMutation } from "./use-browser-scoped-mutation";
98
+ import { useBrowserVideo } from "./use-browser-video";
99
+ import { useBrowserViewerRecovery } from "./use-browser-viewer-recovery";
100
+ import { useResolutionFavorites } from "./use-resolution-favorites";
101
+
102
+ // Preserve the existing contribution entry/test import while ownership lives in a focused module.
103
+ export { contributeSharedBrowserClient } from "./browser-client-presence";
104
+
77
105
  const MAX_VIEWER_LABEL_LENGTH = 64;
78
- const PILL_PRESENCE_POLL_MS = 2_000;
79
- const AGENT_DIRECTORY_PAGE_LIMIT = 200;
80
106
  const MAX_URL_LENGTH = 8_192;
81
- const MAX_TEXT_LENGTH = 4_000;
82
107
  const BYTES_PER_KIBIBYTE = 1_024;
83
- const MAX_SCROLL_DELTA = 4_000;
84
108
 
85
- type Theme = PluginWorkspacePanelProps["theme"];
86
- type InteractionMode = "click" | "double" | "right";
87
- type SwipeMode = "scroll" | "drag";
88
109
  type SpecialKey = Extract<BrowserInputEvent, { kind: "key" }>["key"];
89
110
 
90
111
  interface Size {
@@ -92,9 +113,19 @@ interface Size {
92
113
  height: number;
93
114
  }
94
115
 
95
- interface DisplayPoint {
96
- x: number;
97
- y: number;
116
+ interface ControlledActions {
117
+ navigate(action: "back" | "forward" | "reload" | "goto", url?: string): void;
118
+ applyViewport(): void;
119
+ fillViewport(): void;
120
+ selectDevicePreset(id: DevicePresetId): void;
121
+ changeDensity(value: CaptureDensityMode): void;
122
+ toggleEmulation(): void;
123
+ sendKey(key: SpecialKey): void;
124
+ openCompose(kind: "compose" | "commit"): void;
125
+ closeTab(): void;
126
+ closeBrowser(): void;
127
+ confirmCloseTab(): void;
128
+ confirmCloseBrowser(): void;
98
129
  }
99
130
 
100
131
  const SPECIAL_KEYS: readonly { key: SpecialKey; label: string }[] = [
@@ -137,840 +168,41 @@ function hasUnknownMutationOutcome(error: unknown): boolean {
137
168
  return dispatched && /(transport|connection|timeout)/.test(code || name.toLowerCase());
138
169
  }
139
170
 
140
- function isFrameCurrent(frame: BrowserFrame, state: BrowserState): boolean {
171
+ /** A painted document may remain visible within the same source and geometry. */
172
+ function isFrameDisplayCurrent(frame: BrowserFrameAuthority, state: BrowserState): boolean {
173
+ return Boolean(
174
+ frame.runtimeId &&
175
+ state.runtimeId &&
176
+ frame.captureEpoch !== undefined &&
177
+ state.bridgeEpoch !== undefined &&
178
+ frame.sessionId === state.sessionId &&
179
+ frame.runtimeId === state.runtimeId &&
180
+ frame.captureEpoch === state.bridgeEpoch &&
181
+ frame.viewportGeneration === state.viewportGeneration,
182
+ );
183
+ }
184
+
185
+ function isFrameCurrent(frame: BrowserFrameAuthority, state: BrowserState): boolean {
141
186
  return (
142
187
  frame.sessionId === state.sessionId &&
143
188
  (!frame.runtimeId || !state.runtimeId || frame.runtimeId === state.runtimeId) &&
189
+ (frame.captureEpoch === undefined ||
190
+ state.bridgeEpoch === undefined ||
191
+ frame.captureEpoch === state.bridgeEpoch) &&
144
192
  frame.navigationGeneration === state.navigationGeneration &&
145
193
  frame.viewportGeneration === state.viewportGeneration
146
194
  );
147
195
  }
148
196
 
149
- function clampScrollDelta(delta: number): number {
150
- return Math.max(-MAX_SCROLL_DELTA, Math.min(MAX_SCROLL_DELTA, Math.round(delta)));
151
- }
152
-
153
- function createStyles(theme: Theme, compact: boolean) {
154
- return StyleSheet.create({
155
- screen: {
156
- flex: 1,
157
- minHeight: 0,
158
- backgroundColor: theme.colors.surface0,
159
- },
160
- statusRow: {
161
- minHeight: 30,
162
- paddingHorizontal: SPACE.sm,
163
- paddingVertical: SPACE.xxs,
164
- flexDirection: "row",
165
- alignItems: "center",
166
- justifyContent: "space-between",
167
- gap: SPACE.sm,
168
- borderBottomWidth: 1,
169
- borderBottomColor: theme.colors.border,
170
- backgroundColor: theme.colors.surface0,
171
- },
172
- statusSummary: {
173
- minWidth: 0,
174
- flex: 1,
175
- flexDirection: "row",
176
- alignItems: "center",
177
- flexWrap: "wrap",
178
- gap: SPACE.sm,
179
- },
180
- statusDot: {
181
- width: SPACE.sm,
182
- height: SPACE.sm,
183
- borderRadius: RADIUS.sm,
184
- },
185
- statusText: {
186
- color: theme.colors.foreground,
187
- fontSize: TYPE.body,
188
- fontWeight: "600",
189
- },
190
- mutedText: {
191
- color: theme.colors.foregroundMuted,
192
- fontSize: TYPE.caption,
193
- },
194
- controllerText: {
195
- color: theme.colors.foregroundMuted,
196
- fontSize: TYPE.caption,
197
- flexShrink: 1,
198
- },
199
- actionRow: {
200
- flexDirection: "row",
201
- alignItems: "center",
202
- gap: SPACE.xs,
203
- },
204
- chrome: {
205
- minHeight: 40,
206
- paddingHorizontal: SPACE.sm,
207
- paddingVertical: SPACE.xs,
208
- borderBottomWidth: 1,
209
- borderBottomColor: theme.colors.border,
210
- backgroundColor: theme.colors.surface0,
211
- },
212
- addressRow: {
213
- flexDirection: "row",
214
- alignItems: "center",
215
- gap: SPACE.xs,
216
- },
217
- addressInput: {
218
- flex: 1,
219
- minWidth: compact ? DIMENSION.addressCompact : DIMENSION.addressRegular,
220
- },
221
- chromeAddressInput: {
222
- height: 28,
223
- borderRadius: RADIUS.md,
224
- backgroundColor: theme.colors.surface1,
225
- },
226
- chromeIconButton: {
227
- width: 28,
228
- height: 28,
229
- borderRadius: RADIUS.md,
230
- alignItems: "center",
231
- justifyContent: "center",
232
- },
233
- chromeIconButtonHovered: {
234
- backgroundColor: theme.colors.surface2,
235
- },
236
- chromeIconButtonPressed: {
237
- opacity: 0.72,
238
- },
239
- chromeIconButtonDisabled: {
240
- opacity: 0.45,
241
- },
242
- toolbarContent: {
243
- flexDirection: "row",
244
- alignItems: "center",
245
- gap: SPACE.xs,
246
- },
247
- button: {
248
- minHeight: DIMENSION.control,
249
- minWidth: DIMENSION.control,
250
- paddingHorizontal: SPACE.sm,
251
- borderWidth: 1,
252
- borderColor: theme.colors.border,
253
- borderRadius: RADIUS.sm,
254
- backgroundColor: theme.colors.surface2,
255
- flexDirection: "row",
256
- alignItems: "center",
257
- justifyContent: "center",
258
- gap: SPACE.xs,
259
- },
260
- buttonSelected: {
261
- borderColor: theme.colors.accent,
262
- backgroundColor: theme.colors.accent,
263
- },
264
- buttonPrimary: {
265
- borderColor: theme.colors.accent,
266
- backgroundColor: theme.colors.accent,
267
- },
268
- buttonDanger: {
269
- borderColor: theme.colors.statusWarning,
270
- },
271
- buttonHovered: {
272
- borderColor: theme.colors.foregroundMuted,
273
- },
274
- buttonPressed: {
275
- opacity: 0.72,
276
- },
277
- buttonFocused: {
278
- borderColor: theme.colors.accent,
279
- borderWidth: 2,
280
- },
281
- buttonDisabled: {
282
- opacity: 0.42,
283
- },
284
- buttonText: {
285
- color: theme.colors.foreground,
286
- fontSize: TYPE.caption,
287
- fontWeight: "600",
288
- },
289
- buttonTextSelected: {
290
- color: theme.colors.accentForeground,
291
- },
292
- field: {
293
- height: DIMENSION.control,
294
- paddingHorizontal: SPACE.sm,
295
- borderWidth: 1,
296
- borderColor: theme.colors.border,
297
- borderRadius: RADIUS.sm,
298
- color: theme.colors.foreground,
299
- backgroundColor: theme.colors.surface2,
300
- fontSize: TYPE.body,
301
- },
302
- fieldFocused: {
303
- borderColor: theme.colors.accent,
304
- borderWidth: 2,
305
- },
306
- fieldDisabled: {
307
- opacity: 0.5,
308
- },
309
- errorRow: {
310
- paddingHorizontal: SPACE.sm,
311
- paddingVertical: SPACE.xs,
312
- borderLeftWidth: SPACE.xs,
313
- borderLeftColor: theme.colors.statusDanger,
314
- backgroundColor: theme.colors.surface1,
315
- flexDirection: "row",
316
- alignItems: "center",
317
- gap: SPACE.sm,
318
- },
319
- errorText: {
320
- flex: 1,
321
- color: theme.colors.statusDanger,
322
- fontSize: TYPE.caption,
323
- },
324
- canvasShell: {
325
- flex: 1,
326
- minHeight: compact ? DIMENSION.canvasCompact : DIMENSION.canvasRegular,
327
- minWidth: 0,
328
- margin: compact ? SPACE.sm : 0,
329
- borderRadius: compact ? DIMENSION.screenRadius : 0,
330
- overflow: "hidden",
331
- backgroundColor: theme.colors.surface1,
332
- },
333
- canvas: {
334
- flex: 1,
335
- minHeight: 0,
336
- overflow: "hidden",
337
- },
338
- frame: {
339
- position: "absolute",
340
- borderRadius: compact ? DIMENSION.screenRadius - SPACE.xs : 0,
341
- },
342
- interactionLayer: {
343
- position: "absolute",
344
- },
345
- canvasState: {
346
- flex: 1,
347
- alignItems: "center",
348
- justifyContent: "center",
349
- padding: SPACE.lg,
350
- gap: SPACE.sm,
351
- },
352
- canvasTitle: {
353
- color: theme.colors.foreground,
354
- fontSize: TYPE.title,
355
- fontWeight: "600",
356
- textAlign: "center",
357
- },
358
- canvasDetail: {
359
- color: theme.colors.foregroundMuted,
360
- fontSize: TYPE.caption,
361
- textAlign: "center",
362
- maxWidth: DIMENSION.helperMax,
363
- },
364
- canvasFooter: {
365
- minHeight: DIMENSION.control,
366
- paddingHorizontal: SPACE.sm,
367
- borderTopWidth: 1,
368
- borderTopColor: theme.colors.border,
369
- flexDirection: "row",
370
- alignItems: "center",
371
- justifyContent: "space-between",
372
- gap: SPACE.sm,
373
- backgroundColor: theme.colors.surface1,
374
- },
375
- canvasFooterText: {
376
- color: theme.colors.foregroundMuted,
377
- fontSize: TYPE.caption,
378
- flexShrink: 1,
379
- },
380
- controls: {
381
- paddingHorizontal: compact ? SPACE.md : SPACE.sm,
382
- paddingTop: compact ? SPACE.md : SPACE.sm,
383
- paddingBottom: compact ? SPACE.lg : SPACE.sm,
384
- gap: compact ? SPACE.sm : SPACE.xs,
385
- borderTopWidth: 1,
386
- borderTopColor: theme.colors.border,
387
- backgroundColor: theme.colors.surface0,
388
- },
389
- mobileRow: {
390
- flexDirection: "row",
391
- alignItems: "center",
392
- gap: SPACE.sm,
393
- },
394
- sheetContent: {
395
- gap: SPACE.md,
396
- padding: SPACE.sm,
397
- },
398
- sheetGrid: {
399
- flexDirection: "row",
400
- flexWrap: "wrap",
401
- gap: SPACE.sm,
402
- },
403
- sheetPad: {
404
- alignItems: "center",
405
- gap: SPACE.sm,
406
- },
407
- sheetPadRow: {
408
- flexDirection: "row",
409
- alignItems: "center",
410
- justifyContent: "center",
411
- gap: SPACE.sm,
412
- },
413
- padSpacer: {
414
- width: DIMENSION.pad,
415
- },
416
- controlStrip: {
417
- minHeight: DIMENSION.control,
418
- flexDirection: "row",
419
- alignItems: "center",
420
- gap: SPACE.xs,
421
- },
422
- stripLabel: {
423
- color: theme.colors.foregroundMuted,
424
- fontSize: TYPE.caption,
425
- fontWeight: "600",
426
- marginRight: SPACE.xs,
427
- },
428
- separator: {
429
- width: 1,
430
- alignSelf: "stretch",
431
- marginVertical: SPACE.xs,
432
- marginHorizontal: SPACE.xs,
433
- backgroundColor: theme.colors.border,
434
- },
435
- viewportField: {
436
- width: DIMENSION.viewportField,
437
- textAlign: "center",
438
- },
439
- multiply: {
440
- color: theme.colors.foregroundMuted,
441
- fontSize: TYPE.body,
442
- },
443
- typeRow: {
444
- flexDirection: compact ? "column" : "row",
445
- gap: SPACE.xs,
446
- },
447
- typeInput: {
448
- flex: 1,
449
- minWidth: compact ? undefined : DIMENSION.typeField,
450
- },
451
- keysContent: {
452
- flexDirection: "row",
453
- alignItems: "center",
454
- gap: SPACE.xs,
455
- paddingTop: SPACE.xs,
456
- },
457
- deviceModalContent: {
458
- gap: SPACE.sm,
459
- padding: SPACE.sm,
460
- },
461
- devicePresetRow: {
462
- minHeight: 46,
463
- paddingHorizontal: SPACE.md,
464
- paddingVertical: SPACE.sm,
465
- borderWidth: 1,
466
- borderColor: theme.colors.border,
467
- borderRadius: RADIUS.md,
468
- backgroundColor: theme.colors.surface1,
469
- flexDirection: "row",
470
- alignItems: "center",
471
- gap: SPACE.sm,
472
- },
473
- devicePresetRowSelected: {
474
- borderColor: theme.colors.accent,
475
- backgroundColor: theme.colors.surface2,
476
- },
477
- devicePresetText: {
478
- flex: 1,
479
- color: theme.colors.foreground,
480
- fontSize: TYPE.body,
481
- fontWeight: "600",
482
- },
483
- devicePresetDetail: {
484
- color: theme.colors.foregroundMuted,
485
- fontSize: TYPE.caption,
486
- },
487
- customViewportRow: {
488
- flexDirection: "row",
489
- alignItems: "center",
490
- gap: SPACE.xs,
491
- },
492
- buttonLarge: {
493
- minHeight: DIMENSION.touch,
494
- paddingHorizontal: SPACE.md,
495
- borderRadius: RADIUS.md,
496
- },
497
- buttonFill: {
498
- flex: 1,
499
- },
500
- buttonPad: {
501
- width: DIMENSION.pad,
502
- minHeight: DIMENSION.touch,
503
- },
504
- });
505
- }
506
-
507
- interface ControlButtonStyles {
508
- button: ViewStyle;
509
- buttonSelected: ViewStyle;
510
- buttonPrimary: ViewStyle;
511
- buttonDanger: ViewStyle;
512
- buttonHovered: ViewStyle;
513
- buttonPressed: ViewStyle;
514
- buttonFocused: ViewStyle;
515
- buttonDisabled: ViewStyle;
516
- buttonLarge: ViewStyle;
517
- buttonFill: ViewStyle;
518
- buttonPad: ViewStyle;
519
- buttonText: TextStyle;
520
- buttonTextSelected: TextStyle;
521
- }
522
-
523
- interface FieldStyles {
524
- field: TextStyle;
525
- fieldFocused: TextStyle;
526
- fieldDisabled: TextStyle;
527
- }
528
-
529
- interface ErrorNoticeStyles extends ControlButtonStyles {
530
- errorRow: ViewStyle;
531
- errorText: TextStyle;
532
- }
533
-
534
- interface CanvasPlaceholderStyles {
535
- canvasState: ViewStyle;
536
- canvasTitle: TextStyle;
537
- canvasDetail: TextStyle;
538
- }
539
-
540
- interface ControlButtonProps {
541
- styles: ControlButtonStyles;
542
- theme: Theme;
543
- label: string;
544
- accessibilityLabel?: string;
545
- icon?: string;
546
- selected?: boolean;
547
- primary?: boolean;
548
- danger?: boolean;
549
- large?: boolean;
550
- fill?: boolean;
551
- pad?: boolean;
552
- disabled?: boolean;
553
- onPress(): void;
554
- }
555
-
556
- function ControlButton({
557
- styles,
558
- theme,
559
- label,
560
- accessibilityLabel,
561
- icon,
562
- selected = false,
563
- primary = false,
564
- danger = false,
565
- large = false,
566
- fill = false,
567
- pad = false,
568
- disabled = false,
569
- onPress,
570
- }: ControlButtonProps) {
571
- const [focused, setFocused] = useState(false);
572
- const [hovered, setHovered] = useState(false);
573
- const highlighted = selected || primary;
574
- return (
575
- <Pressable
576
- accessibilityRole="button"
577
- accessibilityLabel={accessibilityLabel ?? label}
578
- accessibilityState={{ disabled, selected }}
579
- disabled={disabled}
580
- onFocus={() => setFocused(true)}
581
- onBlur={() => setFocused(false)}
582
- onHoverIn={() => setHovered(true)}
583
- onHoverOut={() => setHovered(false)}
584
- onPress={onPress}
585
- style={({ pressed }) => [
586
- styles.button,
587
- selected ? styles.buttonSelected : null,
588
- primary ? styles.buttonPrimary : null,
589
- danger ? styles.buttonDanger : null,
590
- hovered && !highlighted ? styles.buttonHovered : null,
591
- pressed ? styles.buttonPressed : null,
592
- focused ? styles.buttonFocused : null,
593
- disabled ? styles.buttonDisabled : null,
594
- large || pad ? styles.buttonLarge : null,
595
- fill ? styles.buttonFill : null,
596
- pad ? styles.buttonPad : null,
597
- ]}
598
- >
599
- {icon ? (
600
- <Icon
601
- name={icon}
602
- size={DIMENSION.icon}
603
- color={highlighted ? theme.colors.accentForeground : theme.colors.foregroundMuted}
604
- />
605
- ) : null}
606
- <Text style={[styles.buttonText, highlighted ? styles.buttonTextSelected : null]}>
607
- {label}
608
- </Text>
609
- </Pressable>
610
- );
611
- }
612
-
613
- interface ChromeIconButtonStyles {
614
- chromeIconButton: ViewStyle;
615
- chromeIconButtonHovered: ViewStyle;
616
- chromeIconButtonPressed: ViewStyle;
617
- chromeIconButtonDisabled: ViewStyle;
618
- }
619
-
620
- function ChromeIconButton({
621
- styles,
622
- theme,
623
- label,
624
- icon,
625
- selected = false,
626
- disabled = false,
627
- onPress,
628
- }: {
629
- styles: ChromeIconButtonStyles;
630
- theme: Theme;
631
- label: string;
632
- icon: string;
633
- selected?: boolean;
634
- disabled?: boolean;
635
- onPress(): void;
636
- }) {
637
- const [hovered, setHovered] = useState(false);
638
- return (
639
- <Pressable
640
- accessibilityRole="button"
641
- accessibilityLabel={label}
642
- accessibilityState={{ disabled, selected }}
643
- disabled={disabled}
644
- onHoverIn={() => setHovered(true)}
645
- onHoverOut={() => setHovered(false)}
646
- onPress={onPress}
647
- style={({ pressed }) => [
648
- styles.chromeIconButton,
649
- hovered ? styles.chromeIconButtonHovered : null,
650
- pressed ? styles.chromeIconButtonPressed : null,
651
- disabled ? styles.chromeIconButtonDisabled : null,
652
- ]}
653
- >
654
- <Icon
655
- name={icon}
656
- size={16}
657
- color={selected ? theme.colors.accent : theme.colors.foregroundMuted}
658
- />
659
- </Pressable>
660
- );
661
- }
662
-
663
- interface FieldProps {
664
- styles: FieldStyles;
665
- theme: Theme;
666
- value: string;
667
- accessibilityLabel: string;
668
- placeholder?: string;
669
- editable?: boolean;
670
- maxLength?: number;
671
- keyboardType?: TextInputProps["keyboardType"];
672
- inputMode?: TextInputProps["inputMode"];
673
- returnKeyType?: TextInputProps["returnKeyType"];
674
- selectTextOnFocus?: boolean;
675
- style?: StyleProp<TextStyle>;
676
- onChangeText(value: string): void;
677
- onSubmit?(): void;
678
- onFocus?(): void;
679
- onBlur?(): void;
680
- }
681
-
682
- function Field({
683
- styles,
684
- theme,
685
- value,
686
- accessibilityLabel,
687
- placeholder,
688
- editable = true,
689
- maxLength,
690
- keyboardType,
691
- inputMode,
692
- returnKeyType,
693
- selectTextOnFocus,
694
- style,
695
- onChangeText,
696
- onSubmit,
697
- onFocus,
698
- onBlur,
699
- }: FieldProps) {
700
- const [focused, setFocused] = useState(false);
701
- return (
702
- <TextInput
703
- accessibilityLabel={accessibilityLabel}
704
- autoCapitalize="none"
705
- autoCorrect={false}
706
- spellCheck={false}
707
- editable={editable}
708
- keyboardType={keyboardType}
709
- inputMode={inputMode}
710
- maxLength={maxLength}
711
- onBlur={() => {
712
- setFocused(false);
713
- onBlur?.();
714
- }}
715
- onChangeText={onChangeText}
716
- onFocus={() => {
717
- setFocused(true);
718
- onFocus?.();
719
- }}
720
- onSubmitEditing={onSubmit}
721
- placeholder={placeholder}
722
- placeholderTextColor={theme.colors.foregroundMuted}
723
- returnKeyType={returnKeyType}
724
- selectionColor={theme.colors.accent}
725
- selectTextOnFocus={selectTextOnFocus}
726
- style={[
727
- styles.field,
728
- style,
729
- focused ? styles.fieldFocused : null,
730
- !editable ? styles.fieldDisabled : null,
731
- ]}
732
- value={value}
733
- />
734
- );
735
- }
736
-
737
- function ErrorNotice({
738
- styles,
739
- theme,
740
- message,
741
- action,
742
- onAction,
743
- actionDisabled = false,
744
- }: {
745
- styles: ErrorNoticeStyles;
746
- theme: Theme;
747
- message: string;
748
- action?: string | undefined;
749
- onAction?: (() => void) | undefined;
750
- actionDisabled?: boolean;
751
- }) {
752
- return (
753
- <View accessibilityRole="alert" style={styles.errorRow}>
754
- <Icon name="CircleAlert" size={DIMENSION.icon} color={theme.colors.statusDanger} />
755
- <Text style={styles.errorText}>{message}</Text>
756
- {action && onAction ? (
757
- <ControlButton
758
- styles={styles}
759
- theme={theme}
760
- label={action}
761
- disabled={actionDisabled}
762
- onPress={onAction}
763
- />
764
- ) : null}
765
- </View>
766
- );
767
- }
768
-
769
- function CanvasPlaceholder({
770
- styles,
771
- theme,
772
- title,
773
- detail,
774
- loading = false,
775
- }: {
776
- styles: CanvasPlaceholderStyles;
777
- theme: Theme;
778
- title: string;
779
- detail: string;
780
- loading?: boolean;
781
- }) {
782
- return (
783
- <View style={styles.canvasState}>
784
- {loading ? <ActivityIndicator color={theme.colors.accent} /> : null}
785
- <Text style={styles.canvasTitle}>{title}</Text>
786
- <Text style={styles.canvasDetail}>{detail}</Text>
787
- </View>
788
- );
789
- }
790
-
791
- interface AgentDirectoryPage {
792
- entries: Array<{ agent: { id: string; workspaceId?: string | undefined } }>;
793
- pageInfo: { hasMore: boolean; nextCursor: string | null };
794
- }
795
-
796
- type AgentDirectoryUpdate =
797
- | { kind: "remove"; agentId: string }
798
- | { kind: "upsert"; agent: { id: string; workspaceId?: string | undefined } };
799
- type AgentPlacement = { id: string; workspaceId: string };
800
-
801
- export function contributeSharedBrowserClient(client: PluginClientContext) {
802
- const agents = new Map<string, AgentPlacement>();
803
- const pills = new Map<string, { workspaceId: string; remove: () => void }>();
804
- const lifetime = new AbortController();
805
- let openWorkspaceIds = new Set<string>();
806
- let refreshing = false;
807
- let stopped = false;
808
- let directoryGeneration = 0;
809
- let pendingDirectory: { generation: number; updates: AgentDirectoryUpdate[] } | null = null;
810
- let unsubscribeDirectory: (() => void) | null = null;
811
- let releaseDirectory: (() => Promise<void>) | null = null;
812
-
813
- const removePill = (agentId: string) => {
814
- pills.get(agentId)?.remove();
815
- pills.delete(agentId);
816
- };
817
- const syncPill = (agent: AgentPlacement) => {
818
- const current = pills.get(agent.id);
819
- if (!openWorkspaceIds.has(agent.workspaceId)) {
820
- removePill(agent.id);
821
- return;
822
- }
823
- if (current?.workspaceId === agent.workspaceId) return;
824
- removePill(agent.id);
825
- const workspaceId = agent.workspaceId;
826
- const pill = client.addComposerPill({
827
- id: "open-shared-browser",
828
- workspaceId,
829
- agentId: agent.id,
830
- button: {
831
- title: "Open Shared Browser",
832
- icon: "PanelsTopLeft",
833
- label: "Shared Browser",
834
- behavior: {
835
- kind: "action",
836
- onPress() {
837
- client.openPanel("shared-browser", { workspaceId });
838
- },
839
- },
840
- },
841
- });
842
- pills.set(agent.id, { workspaceId, remove: pill.remove });
843
- };
844
- const syncAllPills = () => {
845
- for (const agent of agents.values()) syncPill(agent);
846
- };
847
- const refreshPresence = async () => {
848
- if (stopped || refreshing) return;
849
- refreshing = true;
850
- try {
851
- const result = await client.rpc(listOpenBrowserWorkspacesRpc, {});
852
- if (stopped) return;
853
- openWorkspaceIds = new Set(result.workspaceIds);
854
- syncAllPills();
855
- } catch {
856
- return;
857
- } finally {
858
- refreshing = false;
859
- }
860
- };
861
- const applyUpdate = (target: Map<string, AgentPlacement>, update: AgentDirectoryUpdate) => {
862
- if (update.kind === "remove") {
863
- target.delete(update.agentId);
864
- return;
865
- }
866
- const { id, workspaceId } = update.agent;
867
- if (workspaceId) target.set(id, { id, workspaceId });
868
- else target.delete(id);
869
- };
870
- const applyLiveUpdate = (update: AgentDirectoryUpdate) => {
871
- if (stopped) return;
872
- pendingDirectory?.updates.push(update);
873
- applyUpdate(agents, update);
874
- if (update.kind === "remove") {
875
- removePill(update.agentId);
876
- return;
877
- }
878
- const { id, workspaceId } = update.agent;
879
- if (!workspaceId) {
880
- removePill(id);
881
- return;
882
- }
883
- syncPill({ id, workspaceId });
884
- void refreshPresence();
885
- };
886
- const replaceAgents = (next: Map<string, AgentPlacement>) => {
887
- for (const agentId of agents.keys()) {
888
- if (!next.has(agentId)) removePill(agentId);
889
- }
890
- agents.clear();
891
- for (const [agentId, agent] of next) agents.set(agentId, agent);
892
- syncAllPills();
893
- };
894
- const followSnapshot = async (snapshot: AgentDirectoryPage) => {
895
- if (stopped) return;
896
- const generation = ++directoryGeneration;
897
- const transaction = { generation, updates: [] as AgentDirectoryUpdate[] };
898
- pendingDirectory = transaction;
899
- const next = new Map<string, AgentPlacement>();
900
- for (const { agent } of snapshot.entries) {
901
- if (agent.workspaceId) next.set(agent.id, { id: agent.id, workspaceId: agent.workspaceId });
902
- }
903
-
904
- try {
905
- let cursor = snapshot.pageInfo.hasMore ? snapshot.pageInfo.nextCursor : null;
906
- while (cursor) {
907
- const page = await client.paseo.agents.list({
908
- scope: "active",
909
- page: { limit: AGENT_DIRECTORY_PAGE_LIMIT, cursor },
910
- signal: lifetime.signal,
911
- });
912
- if (stopped || pendingDirectory?.generation !== generation) return;
913
- for (const { agent } of page.entries) {
914
- if (agent.workspaceId)
915
- next.set(agent.id, { id: agent.id, workspaceId: agent.workspaceId });
916
- }
917
- cursor = page.pageInfo.hasMore ? page.pageInfo.nextCursor : null;
918
- }
919
- if (stopped || pendingDirectory?.generation !== generation) return;
920
- for (const update of transaction.updates) applyUpdate(next, update);
921
- pendingDirectory = null;
922
- replaceAgents(next);
923
- await refreshPresence();
924
- } catch {
925
- if (!stopped && pendingDirectory?.generation === generation) pendingDirectory = null;
926
- }
927
- };
928
-
929
- void client.paseo.agents
930
- .list({
931
- scope: "active",
932
- page: { limit: AGENT_DIRECTORY_PAGE_LIMIT },
933
- subscribe: {},
934
- signal: lifetime.signal,
935
- })
936
- .then(({ subscription }) => {
937
- if (stopped) {
938
- void subscription.release().catch(() => undefined);
939
- return undefined;
940
- }
941
- releaseDirectory = subscription.release;
942
- unsubscribeDirectory = subscription.subscribe({
943
- snapshot: (snapshot) => void followSnapshot(snapshot),
944
- update: (message) => {
945
- if (message.type === "agent_update") applyLiveUpdate(message.payload);
946
- },
947
- });
948
- return undefined;
949
- })
950
- .catch(() => undefined);
951
- const presenceTimer = setInterval(() => void refreshPresence(), PILL_PRESENCE_POLL_MS);
952
-
953
- return () => {
954
- if (stopped) return;
955
- stopped = true;
956
- directoryGeneration += 1;
957
- pendingDirectory = null;
958
- clearInterval(presenceTimer);
959
- unsubscribeDirectory?.();
960
- void releaseDirectory?.().catch(() => undefined);
961
- lifetime.abort();
962
- for (const { remove } of pills.values()) remove();
963
- pills.clear();
964
- agents.clear();
965
- };
966
- }
967
197
  export function SharedBrowserPanel({
968
198
  theme,
969
199
  host,
970
200
  layout,
971
201
  workspaceId,
972
202
  }: PluginWorkspacePanelProps) {
203
+ const queryClient = useQueryClient();
973
204
  const styles = useMemo(() => createStyles(theme, layout.compact), [theme, layout.compact]);
205
+ const { scale: screenPixelRatio } = useWindowDimensions();
974
206
  const viewerLabel = useState(() =>
975
207
  `Paseo ${layout.platform} · ${host.label} · ${Date.now().toString(36)}${Math.random()
976
208
  .toString(36)
@@ -980,40 +212,122 @@ export function SharedBrowserPanel({
980
212
  const attachBrowser = useRpc(attachBrowserRpc);
981
213
  const detachBrowser = useRpc(detachBrowserRpc);
982
214
  const captureBrowser = useRpc(captureBrowserRpc);
215
+ const closeBrowser = useRpc(closeBrowserRpc);
216
+ const closeTab = useRpc(closeBrowserTabRpc);
217
+ const createTab = useRpc(createBrowserTabRpc);
218
+ const listTabs = useRpc(listBrowserTabsRpc);
219
+ const reopenBrowser = useRpc(reopenBrowserRpc);
983
220
  const acquireControl = useRpc(acquireControlRpc);
984
221
  const releaseControl = useRpc(releaseControlRpc);
985
222
  const navigateBrowser = useRpc(navigateBrowserRpc);
986
223
  const resizeBrowser = useRpc(resizeBrowserRpc);
987
224
  const applyDevicePreset = useRpc(applyDevicePresetRpc);
225
+ const setCaptureDensity = useRpc(setCaptureDensityRpc);
988
226
  const sendBrowserInput = useRpc(sendBrowserInputRpc);
227
+ const beginBrowserGesture = useRpc(beginBrowserGestureRpc);
228
+ const updateBrowserGesture = useRpc(updateBrowserGestureRpc);
229
+ const endBrowserGesture = useRpc(endBrowserGestureRpc);
989
230
 
990
231
  const mountedRef = useRef(false);
991
232
  const activeViewerTokenRef = useRef<string | null>(null);
992
233
  const stateRef = useRef<BrowserState | null>(null);
993
- const lifecycleRef = useRef(new FrameLifecycle());
994
- const lifecycle = lifecycleRef.current;
234
+ // Lease observations can change while acquisition awaits with no local token.
235
+ // Count ownership transitions, not expiry renewals or ordinary media frames.
236
+ const controllerObservationRevision = useRef(0);
237
+ const frameRef = useRef<BrowserFrame | null>(null);
238
+ const committedViewport = useRef<{ sessionId: string; generation: number } | null>(null);
995
239
  const lastPointRef = useRef<{ x: number; y: number } | null>(null);
996
- const dragStartRef = useRef<{ x: number; y: number } | null>(null);
997
- const captureInFlightRef = useRef(false);
240
+ const [inputLifecycle] = useState(createFrameLifecycle);
241
+ const [legacyInputBusy, setLegacyInputBusy] = useState(false);
242
+ // A visual-only old-read paint cannot wake strict initial-admission waiters.
243
+ const [admittedPaint, setAdmittedPaint] = useState<{ frameId: string; epoch: number } | null>(
244
+ null,
245
+ );
246
+ /** Notify initial-admission waiters only when the actual painted receipt gains input authority. */
247
+ const publishAdmittedPaint = (frameId: string, epoch: number) => {
248
+ setAdmittedPaint((previous) => {
249
+ if (previous?.frameId === frameId && previous.epoch === epoch) return previous;
250
+ return { frameId, epoch };
251
+ });
252
+ };
998
253
 
999
254
  const [state, setState] = useState<BrowserState | null>(null);
1000
- const [frame, setFrame] = useState<BrowserFrame | null>(null);
1001
- const [inputBusy, setInputBusy] = useState(false);
1002
255
  const [controlToken, setControlToken] = useState<string | null>(null);
1003
256
  const [operationError, setOperationError] = useState<string | null>(null);
1004
- const [imageError, setImageError] = useState(false);
1005
257
  const [reconnecting, setReconnecting] = useState(false);
258
+ const [browserClosed, setBrowserClosed] = useState(false);
259
+ const [selectedTabId, setSelectedTabId] = useState<string | null>(null);
260
+ const selectedTabIdRef = useRef(selectedTabId);
261
+ selectedTabIdRef.current = selectedTabId;
262
+ const [tabMutationPending, setTabMutationPending] = useState(false);
263
+ const [tabCloseConfirmationOpen, setTabCloseConfirmationOpen] = useState(false);
264
+ const [controlRequest, setControlRequest] = useState<ControlRequest | null>(null);
265
+ const [pendingControlAction, setPendingControlAction] = useState<ControlRequest | null>(null);
266
+ const [controlRequestPending, setControlRequestPending] = useState(false);
267
+ const controlRequestPendingRef = useRef(false);
268
+ const controlledActionsRef = useRef<ControlledActions | null>(null);
269
+ const [closeConfirmationOpen, setCloseConfirmationOpen] = useState(false);
270
+ const [closePending, setClosePending] = useState(false);
271
+ const [reopenPending, setReopenPending] = useState(false);
1006
272
  const [runtimeNotice, setRuntimeNotice] = useState<string | null>(null);
1007
273
  const [addressDraft, setAddressDraft] = useState("");
1008
274
  const [addressFocused, setAddressFocused] = useState(false);
1009
275
  const [viewportWidth, setViewportWidth] = useState("");
1010
276
  const [viewportHeight, setViewportHeight] = useState("");
1011
- const [typeDraft, setTypeDraft] = useState("");
1012
- const [interactionMode, setInteractionMode] = useState<InteractionMode>("click");
1013
277
  const [devicePickerOpen, setDevicePickerOpen] = useState(false);
1014
- const [pointerSheetOpen, setPointerSheetOpen] = useState(false);
1015
- const [keysSheetOpen, setKeysSheetOpen] = useState(false);
1016
- const [swipeMode, setSwipeMode] = useState<SwipeMode>(layout.compact ? "scroll" : "drag");
278
+ const preferences = useResolutionFavorites(host.id);
279
+ const [keysSubmenuOpen, setKeysSubmenuOpen] = useState(false);
280
+ const keysAnchorRef = useRef<View | null>(null);
281
+ const [toolbarMenu, setToolbarMenu] = useState<"display" | "actions" | null>(null);
282
+ const [scaleMode, setScaleMode] = useState<BrowserCanvasDisplayMode>("fit");
283
+ const paneRef = useRef<View | null>(null);
284
+ const displayAnchorRef = useRef<View | null>(null);
285
+ const actionsAnchorRef = useRef<View | null>(null);
286
+ const [paneSize, setPaneSize] = useState<Size>({ width: 0, height: 0 });
287
+ const panelScope = `${host.id}/${workspaceId}`;
288
+ const [fillViewportScope, setFillViewportScope] = useState<string | null>(null);
289
+ const fillViewportEnabled = fillViewportScope === panelScope;
290
+ const [densitySelection, setDensitySelection] = useState<{
291
+ scope: string;
292
+ mode: CaptureDensityMode;
293
+ }>({ scope: panelScope, mode: "auto" });
294
+ const densityMode = densitySelection.scope === panelScope ? densitySelection.mode : "auto";
295
+ const [automaticDensityFailureScope, setAutomaticDensityFailureScope] = useState<string | null>(
296
+ null,
297
+ );
298
+ const automaticDensityEnabled =
299
+ densityMode === "auto" && automaticDensityFailureScope !== panelScope;
300
+ useEffect(() => {
301
+ setFillViewportScope(null);
302
+ setDensitySelection({ scope: panelScope, mode: "auto" });
303
+ setAutomaticDensityFailureScope(null);
304
+ }, [panelScope]);
305
+ const panelScopeRef = useRef(panelScope);
306
+ panelScopeRef.current = panelScope;
307
+ const menuRestoreFocus = useRef(true);
308
+ const closeToolbarMenu = useCallback((restoreFocus = true) => {
309
+ menuRestoreFocus.current = restoreFocus;
310
+ setKeysSubmenuOpen(false);
311
+ setToolbarMenu(null);
312
+ }, []);
313
+ const toggleToolbarMenu = (menu: "display" | "actions") => {
314
+ menuRestoreFocus.current = true;
315
+ setKeysSubmenuOpen(false);
316
+ setToolbarMenu((current) => (current === menu ? null : menu));
317
+ };
318
+ const [composeRequest, setComposeRequest] = useState<{
319
+ id: number;
320
+ kind: "compose" | "commit";
321
+ ownershipKey: string;
322
+ } | null>(null);
323
+ const nextComposeRequest = useRef(0);
324
+ const [activeInput, setActiveInput] = useState(false);
325
+ const activeInputRef = useRef(false);
326
+ /** Fence automatic timers immediately, before React commits a new physical press. */
327
+ const handleInputActivity = useCallback((active: boolean) => {
328
+ activeInputRef.current = active;
329
+ setActiveInput(active);
330
+ }, []);
1017
331
  const [containerSize, setContainerSize] = useState<Size>({ width: 0, height: 0 });
1018
332
 
1019
333
  useEffect(() => {
@@ -1023,30 +337,66 @@ export function SharedBrowserPanel({
1023
337
  };
1024
338
  }, []);
1025
339
 
1026
- const acceptState = useCallback((next: BrowserState) => {
1027
- const previous = stateRef.current;
1028
- if (previous && !isBrowserStateCurrent(previous, next)) return false;
1029
- const currentFrame = lifecycle.frame;
1030
- if (previous && didBrowserRuntimeRestart(previous, next)) {
1031
- setRuntimeNotice(
1032
- "Browser restarted. The preserved viewer connection now targets the new runtime.",
1033
- );
1034
- setControlToken(null);
1035
- }
1036
- if (currentFrame && !isFrameCurrent(currentFrame, next)) {
1037
- lifecycle.drop();
1038
- setFrame(null);
1039
- lastPointRef.current = null;
1040
- }
1041
- stateRef.current = next;
1042
- setState(next);
1043
- return true;
340
+ const isCandidateCurrent = useCallback((candidate: FrameCandidate) => {
341
+ const current = stateRef.current;
342
+ return (
343
+ mountedRef.current &&
344
+ candidate.viewerToken === activeViewerTokenRef.current &&
345
+ candidate.mutationEpoch === inputLifecycle.epoch &&
346
+ current !== null &&
347
+ isFrameCurrent(candidate.frame, current)
348
+ );
1044
349
  }, []);
350
+ const { buffer, frame, receive, settled, discardObsoletePending, reset } = useBrowserFrameBuffer(
351
+ frameRef,
352
+ isCandidateCurrent,
353
+ );
354
+ const imageError = buffer.imageError;
355
+
356
+ const acceptState = useCallback(
357
+ (next: BrowserState) => {
358
+ const previous = stateRef.current;
359
+ if (previous && !isBrowserStateCurrent(previous, next)) return false;
360
+ // Server state projects "self" against this exact viewer token. Media can
361
+ // observe our acquisition before its RPC returns the control token, so
362
+ // that transition must not revoke its own still-pending settlement.
363
+ // Competing ownership and subsequent release still advance the fence.
364
+ const observesOwnAcquisition = previous?.controller !== "self" && next.controller === "self";
365
+ if (
366
+ previous &&
367
+ !observesOwnAcquisition &&
368
+ (previous.controller !== next.controller ||
369
+ previous.controllerLabel !== next.controllerLabel)
370
+ ) {
371
+ controllerObservationRevision.current += 1;
372
+ }
373
+ const currentFrame = frameRef.current;
374
+ if (previous && didBrowserRuntimeRestart(previous, next)) {
375
+ setRuntimeNotice(
376
+ "Browser restarted. The preserved viewer connection now targets the new runtime.",
377
+ );
378
+ setControlToken(null);
379
+ }
380
+ if (currentFrame && !isFrameCurrent(currentFrame, next)) {
381
+ lastPointRef.current = null;
382
+ }
383
+ stateRef.current = next;
384
+ // Repeated captures in the new generation must not cancel its slow decoder.
385
+ discardObsoletePending();
386
+ setState(next);
387
+ return true;
388
+ },
389
+ [discardObsoletePending],
390
+ );
1045
391
 
1046
392
  const attachQuery = useQuery({
1047
- queryKey: ["shared-browser", "attach", workspaceId, viewerLabel],
393
+ queryKey: ["shared-browser", "attach", host.id, workspaceId, viewerLabel, selectedTabId],
1048
394
  queryFn: async () => {
1049
- const result = await attachBrowser({ workspaceId, viewerLabel });
395
+ const result = await attachBrowser({
396
+ workspaceId,
397
+ viewerLabel,
398
+ ...(selectedTabId ? { tabId: selectedTabId } : {}),
399
+ });
1050
400
  if (!mountedRef.current) {
1051
401
  await detachBrowser({ viewerToken: result.viewerToken }).catch(() => undefined);
1052
402
  throw new Error("The browser panel closed before attachment completed.");
@@ -1054,12 +404,44 @@ export function SharedBrowserPanel({
1054
404
  return result;
1055
405
  },
1056
406
  retry: false,
407
+ enabled: !browserClosed,
1057
408
  staleTime: Number.POSITIVE_INFINITY,
1058
409
  gcTime: 0,
1059
410
  refetchOnWindowFocus: false,
1060
411
  });
1061
412
 
1062
- const viewerToken = reconnecting ? null : (attachQuery.data?.viewerToken ?? null);
413
+ const closedView = browserClosed || isBrowserClosedError(attachQuery.error);
414
+ const closedViewRef = useRef(closedView);
415
+ closedViewRef.current = closedView;
416
+ const attachedTabMatches = !selectedTabId || attachQuery.data?.state.tabId === selectedTabId;
417
+ const viewerToken =
418
+ reconnecting || closedView || !attachedTabMatches
419
+ ? null
420
+ : (attachQuery.data?.viewerToken ?? null);
421
+ const tabsQueryKey = ["shared-browser", "tabs", host.id, workspaceId];
422
+ const tabsQuery = useQuery({
423
+ queryKey: tabsQueryKey,
424
+ queryFn: () => listTabs({ viewerToken: viewerToken! }),
425
+ enabled: Boolean(viewerToken),
426
+ refetchInterval: 2_000,
427
+ });
428
+
429
+ useEffect(() => {
430
+ const tabs = tabsQuery.data?.tabs;
431
+ const selected = selectedTabId ?? stateRef.current?.tabId;
432
+ if (!tabs || !selected || tabs.some((tab) => tab.id === selected)) return;
433
+ setSelectedTabId(tabs[0]?.id ?? null);
434
+ }, [selectedTabId, tabsQuery.data]);
435
+
436
+ useEffect(() => {
437
+ if (isBrowserClosedError(attachQuery.error)) setBrowserClosed(true);
438
+ }, [attachQuery.error]);
439
+
440
+ useEffect(() => {
441
+ if (!closedView) return;
442
+ setControlRequest(null);
443
+ setPendingControlAction(null);
444
+ }, [closedView]);
1063
445
 
1064
446
  useEffect(() => {
1065
447
  if (!viewerToken) return;
@@ -1076,56 +458,128 @@ export function SharedBrowserPanel({
1076
458
 
1077
459
  useEffect(() => {
1078
460
  stateRef.current = null;
1079
- lifecycle.reset();
461
+ frameRef.current = null;
1080
462
  setState(null);
1081
- setFrame(null);
1082
- setInputBusy(false);
463
+ reset();
464
+ inputLifecycle.bump();
465
+ inputLifecycle.reset();
466
+ setLegacyInputBusy(false);
1083
467
  setControlToken(null);
1084
468
  setOperationError(null);
1085
469
  setRuntimeNotice(null);
1086
- }, [workspaceId]);
470
+ setBrowserClosed(false);
471
+ setSelectedTabId(null);
472
+ setTabCloseConfirmationOpen(false);
473
+ setControlRequest(null);
474
+ setPendingControlAction(null);
475
+ setCloseConfirmationOpen(false);
476
+ }, [reset, workspaceId, host.id, inputLifecycle]);
1087
477
 
1088
- const captureQuery = useQuery({
1089
- queryKey: ["shared-browser", "capture", viewerToken],
1090
- queryFn: async () => {
1091
- if (!viewerToken) throw new Error("The browser viewer is not attached.");
1092
- const mutationEpoch = lifecycle.epoch;
1093
- const knownFrame = lifecycle.frame;
1094
- captureInFlightRef.current = true;
1095
- try {
1096
- const result = await captureBrowser({
1097
- viewerToken,
1098
- quality: "medium",
1099
- knownFrameId: knownFrame?.frameId ?? null,
1100
- });
1101
- return { ...result, mutationEpoch };
1102
- } finally {
1103
- captureInFlightRef.current = false;
1104
- }
478
+ useEffect(() => {
479
+ // A tab selection changes the viewer's page and frame authority, even
480
+ // though the underlying Chromium process and saved profile stay shared.
481
+ inputLifecycle.bump();
482
+ activeViewerTokenRef.current = null;
483
+ stateRef.current = null;
484
+ frameRef.current = null;
485
+ reset();
486
+ setState(null);
487
+ setControlToken(null);
488
+ setControlRequest(null);
489
+ setPendingControlAction(null);
490
+ setOperationError(null);
491
+ setReconnecting(false);
492
+ }, [selectedTabId, inputLifecycle, reset]);
493
+
494
+ useLayoutEffect(() => {
495
+ committedViewport.current = state
496
+ ? { sessionId: state.sessionId, generation: state.viewportGeneration }
497
+ : null;
498
+ }, [state?.sessionId, state?.viewportGeneration]);
499
+
500
+ const video = useBrowserVideo({
501
+ viewerToken,
502
+ quality: "high",
503
+ bitrate: preferences.videoBitrate,
504
+ fps: preferences.videoFps,
505
+ epoch: () => inputLifecycle.epoch,
506
+ viewport: () => stateRef.current?.viewport ?? null,
507
+ isCurrent: (packet, epoch) =>
508
+ mountedRef.current &&
509
+ activeViewerTokenRef.current === viewerToken &&
510
+ epoch === inputLifecycle.epoch &&
511
+ committedViewport.current?.sessionId === packet.frame.sessionId &&
512
+ committedViewport.current?.generation === packet.frame.viewportGeneration &&
513
+ stateRef.current !== null &&
514
+ isFrameCurrent(packet.frame, stateRef.current),
515
+ isDisplayCurrent: (packet) => {
516
+ const current = stateRef.current;
517
+ return (
518
+ mountedRef.current &&
519
+ activeViewerTokenRef.current === viewerToken &&
520
+ current !== null &&
521
+ isFrameDisplayCurrent(packet.frame, current)
522
+ );
523
+ },
524
+ acceptState,
525
+ onPresented: (packet, epoch) => {
526
+ inputLifecycle.accept(epoch, packet.frame);
527
+ publishAdmittedPaint(packet.frame.frameId, epoch);
528
+ setLegacyInputBusy(inputLifecycle.busy);
1105
529
  },
1106
- enabled: Boolean(viewerToken),
1107
- retry: false,
1108
- refetchInterval: (query) =>
1109
- query.state.data?.state.status === "ready"
1110
- ? CAPTURE_INTERVAL_READY
1111
- : CAPTURE_INTERVAL_WAITING,
1112
- refetchIntervalInBackground: false,
1113
- refetchOnWindowFocus: true,
1114
- staleTime: 0,
530
+ });
531
+
532
+ const {
533
+ query: captureQuery,
534
+ refreshCapture,
535
+ retryFrameCapture,
536
+ } = useBrowserImageCapture({
537
+ active: !video.supported || video.active,
538
+ viewerToken,
539
+ quality: preferences.captureQuality,
540
+ activeInput,
541
+ fallbackRevision: video.fallbackRevision,
542
+ videoOwnsPresentation: Boolean(
543
+ video.front &&
544
+ !video.fallbackRevision &&
545
+ state &&
546
+ isFrameDisplayCurrent(video.front.frame, state),
547
+ ),
548
+ hasVideoPresentation: () =>
549
+ Boolean(
550
+ video.front &&
551
+ !video.fallbackRevision &&
552
+ activeViewerTokenRef.current === viewerToken &&
553
+ stateRef.current &&
554
+ isFrameDisplayCurrent(video.front.frame, stateRef.current),
555
+ ),
556
+ refreshVideo: video.refresh,
557
+ mutationEpoch: () => inputLifecycle.epoch,
558
+ knownFrame: () => frameRef.current,
559
+ capture: captureBrowser,
1115
560
  });
1116
561
 
1117
562
  useEffect(() => {
1118
563
  const result = captureQuery.data;
1119
- if (!result || !lifecycle.isCurrent(result.mutationEpoch) || !acceptState(result.state)) {
564
+ if (
565
+ !result ||
566
+ result.viewerToken !== activeViewerTokenRef.current ||
567
+ result.captureQuality !== preferences.captureQuality ||
568
+ result.mutationEpoch !== inputLifecycle.epoch ||
569
+ !acceptState(result.state)
570
+ ) {
1120
571
  return;
1121
572
  }
1122
573
  if (result.frame && isFrameCurrent(result.frame, result.state)) {
1123
- lifecycle.accept(result.mutationEpoch, result.frame);
1124
- setImageError(false);
1125
- setFrame(lifecycle.visible);
1126
- setInputBusy(lifecycle.busy);
574
+ receive({
575
+ frame: result.frame,
576
+ viewport: result.state.viewport,
577
+ viewerToken: result.viewerToken,
578
+ mutationEpoch: result.mutationEpoch,
579
+ fallbackRevision: result.fallbackRevision,
580
+ });
1127
581
  }
1128
- }, [acceptState, captureQuery.data]);
582
+ }, [acceptState, captureQuery.data, preferences.captureQuality, receive]);
1129
583
 
1130
584
  useEffect(() => {
1131
585
  if (state?.controller !== "self" && controlToken) setControlToken(null);
@@ -1142,18 +596,6 @@ export function SharedBrowserPanel({
1142
596
  setViewportHeight(String(state.viewport.height));
1143
597
  }, [state?.sessionId, state?.viewport.height, state?.viewport.width]);
1144
598
 
1145
- useEffect(() => {
1146
- setImageError(false);
1147
- }, [frame?.frameId]);
1148
-
1149
- const refreshCapture = useCallback(() => {
1150
- const requestWasInFlight = captureInFlightRef.current;
1151
- const request = captureQuery.refetch({ cancelRefetch: false });
1152
- if (requestWasInFlight) {
1153
- void request.then(() => captureQuery.refetch({ cancelRefetch: false }));
1154
- }
1155
- }, [captureQuery.refetch]);
1156
-
1157
599
  const mutationFailed = useCallback(
1158
600
  (error: unknown) => {
1159
601
  const message = errorMessage(error);
@@ -1176,58 +618,123 @@ export function SharedBrowserPanel({
1176
618
  [acceptState, refreshCapture],
1177
619
  );
1178
620
 
1179
- const acquireMutation = useMutation({
621
+ const mutationIdentity = JSON.stringify([
622
+ host.id,
623
+ workspaceId,
624
+ viewerToken,
625
+ controlToken,
626
+ controllerObservationRevision.current,
627
+ ]);
628
+ // Ref observations revoke settlement immediately, before the state commit.
629
+ const currentMutationIdentity = () =>
630
+ JSON.stringify([
631
+ host.id,
632
+ workspaceId,
633
+ activeViewerTokenRef.current,
634
+ controlToken,
635
+ controllerObservationRevision.current,
636
+ ]);
637
+ const acquireMutation = useBrowserScopedMutation({
638
+ identity: mutationIdentity,
639
+ currentIdentity: currentMutationIdentity,
1180
640
  mutationFn: acquireControl,
1181
- retry: false,
1182
641
  onSuccess: (result) => {
1183
642
  setControlToken(result.controlToken);
1184
643
  mutationSucceeded(result.state);
1185
644
  },
1186
645
  onError: mutationFailed,
1187
646
  });
1188
- const releaseMutation = useMutation({
647
+ const releaseMutation = useBrowserScopedMutation({
648
+ identity: mutationIdentity,
649
+ currentIdentity: currentMutationIdentity,
1189
650
  mutationFn: releaseControl,
1190
- retry: false,
1191
651
  onSuccess: (result) => {
1192
652
  setControlToken(null);
1193
653
  mutationSucceeded(result.state);
1194
654
  },
1195
655
  onError: mutationFailed,
1196
656
  });
1197
- const navigateMutation = useMutation({
657
+ const navigateMutation = useBrowserScopedMutation({
658
+ identity: mutationIdentity,
659
+ currentIdentity: currentMutationIdentity,
1198
660
  mutationFn: navigateBrowser,
1199
- retry: false,
1200
- onSuccess: (result) => mutationSucceeded(result.state),
661
+ onSuccess: (result) => {
662
+ mutationSucceeded(result.state);
663
+ // Only acknowledged navigation grants explicit viewing recovery. Ordinary
664
+ // gesture/image refresh does not rearm an exhausted video reader.
665
+ video.retry();
666
+ },
1201
667
  onError: mutationFailed,
1202
668
  });
1203
- const resizeMutation = useMutation({
669
+ const resizeMutation = useBrowserScopedMutation({
670
+ identity: mutationIdentity,
671
+ currentIdentity: currentMutationIdentity,
1204
672
  mutationFn: resizeBrowser,
1205
- retry: false,
1206
- onSuccess: (result) => mutationSucceeded(result.state),
1207
- onError: mutationFailed,
673
+ onSuccess: (result) => {
674
+ setDevicePickerOpen(false);
675
+ mutationSucceeded(result.state);
676
+ },
677
+ onError: (error) => {
678
+ setFillViewportScope(null);
679
+ setAutomaticDensityFailureScope(panelScope);
680
+ mutationFailed(error);
681
+ },
1208
682
  });
1209
- const deviceMutation = useMutation({
683
+ const deviceMutation = useBrowserScopedMutation({
684
+ identity: mutationIdentity,
685
+ currentIdentity: currentMutationIdentity,
1210
686
  mutationFn: applyDevicePreset,
1211
- retry: false,
1212
687
  onSuccess: (result) => mutationSucceeded(result.state),
1213
688
  onError: mutationFailed,
1214
689
  });
1215
- const settleInput = () => {
1216
- lifecycle.settle();
690
+ // Upstream's settlement gate protects discrete key controls and native text fallback.
691
+ // Continuous gestures retain their own ordered channel without waiting per event.
692
+ const settleLegacyInput = () => {
693
+ inputLifecycle.settle();
694
+ setLegacyInputBusy(true);
1217
695
  };
1218
- const inputMutation = useMutation({
696
+ const inputMutation = useBrowserScopedMutation({
697
+ identity: mutationIdentity,
698
+ currentIdentity: currentMutationIdentity,
1219
699
  mutationFn: sendBrowserInput,
1220
- retry: false,
1221
- onSuccess: (result, variables) => {
1222
- if (variables.event.kind === "type") {
1223
- const sentText = variables.event.text;
1224
- setTypeDraft((current) => (current === sentText ? "" : current));
1225
- }
1226
- settleInput();
700
+ onSuccess: (result) => {
701
+ settleLegacyInput();
1227
702
  mutationSucceeded(result.state);
1228
703
  },
1229
704
  onError: (error) => {
1230
- settleInput();
705
+ settleLegacyInput();
706
+ mutationFailed(error);
707
+ },
708
+ });
709
+
710
+ const densityControl = useBrowserCaptureDensity({
711
+ identity: JSON.stringify([host.id, workspaceId, viewerToken, controlToken]),
712
+ current: () => {
713
+ const current = stateRef.current;
714
+ const viewer = activeViewerTokenRef.current;
715
+ if (viewingExpired || !current || !viewer || !controlToken || current.controller !== "self")
716
+ return null;
717
+ return {
718
+ state: current,
719
+ context: {
720
+ viewerToken: viewer,
721
+ controlToken,
722
+ expected: {
723
+ sessionId: current.sessionId,
724
+ navigationGeneration: current.navigationGeneration,
725
+ viewportGeneration: current.viewportGeneration,
726
+ ...(current.runtimeId ? { runtimeId: current.runtimeId } : {}),
727
+ ...(current.bridgeEpoch !== undefined ? { bridgeEpoch: current.bridgeEpoch } : {}),
728
+ },
729
+ },
730
+ };
731
+ },
732
+ change: setCaptureDensity,
733
+ beforeChange: () => inputLifecycle.bump(),
734
+ onSuccess: mutationSucceeded,
735
+ onError: (error) => {
736
+ setAutomaticDensityFailureScope(panelScope);
737
+ setFillViewportScope(null);
1231
738
  mutationFailed(error);
1232
739
  },
1233
740
  });
@@ -1237,47 +744,98 @@ export function SharedBrowserPanel({
1237
744
  releaseMutation.isPending ||
1238
745
  navigateMutation.isPending ||
1239
746
  resizeMutation.isPending ||
747
+ densityControl.pending ||
1240
748
  deviceMutation.isPending ||
1241
- inputMutation.isPending;
1242
- const canControl = Boolean(viewerToken && controlToken && state?.controller === "self");
1243
- const currentFrame = frame && state && isFrameCurrent(frame, state) ? frame : null;
1244
- const canSendInput =
1245
- canControl && Boolean(currentFrame) && !inputMutation.isPending && !inputBusy;
1246
-
1247
- const frameUri = useMemo(
1248
- () => (currentFrame ? `data:${currentFrame.mimeType};base64,${currentFrame.dataBase64}` : null),
1249
- [currentFrame],
1250
- );
1251
- const [imageSize, setImageSize] = useState<{ uri: string; size: Size } | null>(null);
1252
- const loadImageSize = useMemo(
1253
- () =>
1254
- createImageSizeLoader(
1255
- (uri, success, failure) => Image.getSize(uri, success, failure),
1256
- (uri, size) => setImageSize(size ? { uri, size } : null),
1257
- ),
1258
- [],
749
+ inputMutation.isPending ||
750
+ controlRequestPending ||
751
+ closePending;
752
+ const videoViewerExpired =
753
+ video.errorViewerToken === viewerToken && isExpiredBrowserViewerError(video.error);
754
+ const viewingExpired = videoViewerExpired || isExpiredBrowserViewerError(captureQuery.error);
755
+ useEffect(() => {
756
+ if (isBrowserClosedError(captureQuery.error) || isBrowserClosedError(video.error))
757
+ setBrowserClosed(true);
758
+ }, [captureQuery.error, video.error]);
759
+ const canControl = Boolean(
760
+ viewerToken &&
761
+ controlToken &&
762
+ state?.controller === "self" &&
763
+ (!selectedTabId || state.tabId === selectedTabId) &&
764
+ !viewingExpired,
1259
765
  );
766
+ const frontLayer = buffer.front === null ? null : buffer.layers[buffer.front];
767
+ useLayoutEffect(() => {
768
+ const candidate = frontLayer?.candidate;
769
+ if (
770
+ candidate &&
771
+ candidate.fallbackRevision === video.fallbackRevision &&
772
+ candidate.mutationEpoch === inputLifecycle.epoch &&
773
+ candidate.viewerToken === viewerToken &&
774
+ state &&
775
+ isFrameCurrent(candidate.frame, state)
776
+ )
777
+ video.completeFallback(candidate.fallbackRevision ?? 0);
778
+ }, [frontLayer, video.fallbackRevision, state, viewerToken, inputLifecycle]);
779
+ const videoFrame =
780
+ video.front && state && isFrameCurrent(video.front.frame, state) ? video.front.frame : null;
781
+ const currentFrame =
782
+ videoFrame ??
783
+ (frame &&
784
+ state &&
785
+ frontLayer?.candidate.viewerToken === viewerToken &&
786
+ isFrameCurrent(frame, state)
787
+ ? frame
788
+ : null);
1260
789
  useEffect(() => {
1261
- if (frameUri) loadImageSize(frameUri);
1262
- }, [frameUri, loadImageSize]);
1263
- // The bitmap can disagree with the frame metadata; its own aspect ratio decides the rectangle.
1264
- const displayRect = useMemo(
790
+ const candidate = frontLayer?.candidate;
791
+ if (videoFrame || !frame || !candidate || candidate.mutationEpoch !== inputLifecycle.epoch)
792
+ return;
793
+ inputLifecycle.accept(candidate.mutationEpoch, frame);
794
+ publishAdmittedPaint(frame.frameId, candidate.mutationEpoch);
795
+ setLegacyInputBusy(inputLifecycle.busy);
796
+ }, [frame, frontLayer, inputLifecycle, videoFrame]);
797
+ const canSendInput =
798
+ canControl && Boolean(currentFrame) && !inputMutation.isPending && !legacyInputBusy;
799
+
800
+ // Layout belongs to the decoded front frame. A denser capture adds detail,
801
+ // not a larger 100% layout; scale never writes to the shared browser state.
802
+ const visualFrame = video.front?.frame ?? frame;
803
+ const canvasLayout = useMemo(
1265
804
  () =>
1266
- currentFrame
1267
- ? containedRect(
805
+ visualFrame
806
+ ? getBrowserCanvasLayout(
1268
807
  containerSize,
1269
- imageSize?.uri === frameUri
1270
- ? imageSize.size
1271
- : { width: currentFrame.width, height: currentFrame.height },
808
+ visualFrame,
809
+ video.front
810
+ ? (video.frontViewport ?? visualFrame)
811
+ : (frontLayer?.candidate.viewport ?? state?.viewport ?? visualFrame),
812
+ scaleMode,
1272
813
  )
1273
814
  : null,
1274
- [containerSize, currentFrame, frameUri, imageSize],
815
+ [
816
+ containerSize,
817
+ visualFrame,
818
+ frontLayer,
819
+ state?.viewport,
820
+ scaleMode,
821
+ video.front,
822
+ video.frontViewport,
823
+ ],
1275
824
  );
825
+ const displayRect = canvasLayout?.frameRect ?? null;
1276
826
 
1277
827
  const controlContext = useCallback(() => {
1278
828
  const viewer = activeViewerTokenRef.current;
1279
829
  const current = stateRef.current;
1280
- if (!viewer || !controlToken || !current || current.controller !== "self") return null;
830
+ if (
831
+ viewingExpired ||
832
+ !viewer ||
833
+ !controlToken ||
834
+ !current ||
835
+ current.controller !== "self" ||
836
+ (selectedTabId && current.tabId !== selectedTabId)
837
+ )
838
+ return null;
1281
839
  return {
1282
840
  viewerToken: viewer,
1283
841
  controlToken,
@@ -1287,13 +845,25 @@ export function SharedBrowserPanel({
1287
845
  viewportGeneration: current.viewportGeneration,
1288
846
  },
1289
847
  };
1290
- }, [controlToken]);
848
+ }, [controlToken, viewingExpired, selectedTabId]);
1291
849
 
1292
850
  const inputContext = useCallback(() => {
1293
851
  const context = controlContext();
1294
852
  const current = stateRef.current;
1295
- const targetFrame = lifecycle.frame;
1296
- if (!context || !current || !targetFrame || !isFrameCurrent(targetFrame, current)) return null;
853
+ const targetVideo = video.frontRef.current;
854
+ // A revoked video remains painted until React commits the fallback. Never
855
+ // borrow the JPEG token while those older video pixels still cover it.
856
+ if (video.front && !targetVideo) return null;
857
+ const targetFrame = targetVideo?.frame ?? frameRef.current;
858
+ if (
859
+ !context ||
860
+ !current ||
861
+ !targetFrame ||
862
+ !isFrameCurrent(targetFrame, current) ||
863
+ (!targetVideo && frontLayer?.candidate.viewerToken !== context.viewerToken)
864
+ ) {
865
+ return null;
866
+ }
1297
867
  return {
1298
868
  ...context,
1299
869
  target: {
@@ -1302,7 +872,7 @@ export function SharedBrowserPanel({
1302
872
  viewportGeneration: targetFrame.viewportGeneration,
1303
873
  },
1304
874
  };
1305
- }, [controlContext]);
875
+ }, [controlContext, frontLayer, video.frontRef, video.front]);
1306
876
 
1307
877
  const requireControlContext = useCallback(() => {
1308
878
  const context = controlContext();
@@ -1325,81 +895,106 @@ export function SharedBrowserPanel({
1325
895
 
1326
896
  const sendEvent = useCallback(
1327
897
  (event: BrowserInputEvent) => {
1328
- if (lifecycle.busy) return;
1329
898
  const context = requireInputContext();
1330
- if (!context || !lifecycle.begin()) return;
1331
- setInputBusy(true);
899
+ if (!context || inputMutation.isPending || !inputLifecycle.begin()) return;
900
+ setLegacyInputBusy(true);
1332
901
  inputMutation.mutate({ ...context, event });
1333
902
  },
1334
- [inputMutation, lifecycle, requireInputContext],
903
+ [inputMutation, requireInputContext],
1335
904
  );
1336
905
 
1337
- const pointFromEvent = useCallback(
1338
- (event: GestureResponderEvent): DisplayPoint | null => {
1339
- if (!displayRect) return null;
1340
- const x = Math.min(displayRect.width, Math.max(0, event.nativeEvent.locationX));
1341
- const y = Math.min(displayRect.height, Math.max(0, event.nativeEvent.locationY));
1342
- return { x, y };
1343
- },
1344
- [displayRect],
1345
- );
906
+ const liveInputEnabled =
907
+ canSendInput &&
908
+ !navigateMutation.isPending &&
909
+ !resizeMutation.isPending &&
910
+ !deviceMutation.isPending &&
911
+ !releaseMutation.isPending &&
912
+ !acquireMutation.isPending &&
913
+ toolbarMenu === null;
914
+ /** Existing channels retain control incarnation while their next frame decodes. */
915
+ const gestureControlContext = useCallback(() => {
916
+ const context = controlContext();
917
+ const current = stateRef.current;
918
+ if (!mountedRef.current || !context || !current?.runtimeId || current.bridgeEpoch === undefined)
919
+ return null;
920
+ return {
921
+ ...context,
922
+ expected: {
923
+ ...context.expected,
924
+ runtimeId: current.runtimeId,
925
+ bridgeEpoch: current.bridgeEpoch,
926
+ },
927
+ };
928
+ }, [controlContext]);
1346
929
 
1347
- const finishPointerGesture = useCallback(
1348
- (event: GestureResponderEvent) => {
1349
- const start = dragStartRef.current;
1350
- const end = pointFromEvent(event);
1351
- const viewport = stateRef.current?.viewport;
1352
- dragStartRef.current = null;
1353
- if (!start || !end || !displayRect || !viewport) return;
1354
- const toViewport = (point: DisplayPoint) => toViewportPoint(point, displayRect, viewport);
1355
- lastPointRef.current = { x: end.x, y: end.y };
1356
- const distance = Math.hypot(end.x - start.x, end.y - start.y);
1357
- if (distance >= DRAG_THRESHOLD) {
1358
- if (swipeMode === "scroll") {
1359
- const scale = viewport.width / displayRect.width;
1360
- sendEvent({
1361
- kind: "scroll",
1362
- point: toViewport(start),
1363
- deltaX: clampScrollDelta((start.x - end.x) * scale),
1364
- deltaY: clampScrollDelta((start.y - end.y) * scale),
1365
- });
1366
- return;
1367
- }
1368
- sendEvent({
1369
- kind: "drag",
1370
- start: toViewport(start),
1371
- end: toViewport(end),
1372
- button: interactionMode === "right" ? "right" : "left",
1373
- });
1374
- return;
1375
- }
1376
- sendEvent({
1377
- kind: "click",
1378
- point: toViewport(end),
1379
- button: interactionMode === "right" ? "right" : "left",
1380
- clickCount: interactionMode === "double" ? 2 : 1,
1381
- });
930
+ // Forward physical mouse events and genuine touch on every remote preset.
931
+ const canvasInput = useBrowserCanvasInput({
932
+ authority: () => {
933
+ const input = inputContext();
934
+ const context = gestureControlContext();
935
+ return input && context ? { ...context, target: input.target } : null;
936
+ },
937
+ controlAuthority: gestureControlContext,
938
+ transport: { begin: beginBrowserGesture, update: updateBrowserGesture, end: endBrowserGesture },
939
+ ownershipKey: JSON.stringify([
940
+ viewerToken,
941
+ controlToken,
942
+ state?.sessionId,
943
+ state?.runtimeId,
944
+ state?.bridgeEpoch,
945
+ state?.navigationGeneration,
946
+ state?.viewportGeneration,
947
+ displayRect?.width,
948
+ displayRect?.height,
949
+ containerSize.width,
950
+ containerSize.height,
951
+ scaleMode,
952
+ liveInputEnabled,
953
+ ]),
954
+ decodedFrameId: admittedPaint?.epoch === inputLifecycle.epoch ? admittedPaint.frameId : null,
955
+ enabled: liveInputEnabled,
956
+ displaySize: displayRect,
957
+ viewport: state?.viewport ?? null,
958
+ onState: acceptState,
959
+ onError: mutationFailed,
960
+ onFinish: refreshCapture,
961
+ onPoint: (point) => {
962
+ lastPointRef.current = { x: point.x, y: point.y };
1382
963
  },
1383
- [displayRect, interactionMode, pointFromEvent, sendEvent, swipeMode],
1384
- );
964
+ onActivity: handleInputActivity,
965
+ onInputBoundary: () => {
966
+ inputLifecycle.bump();
967
+ },
968
+ });
1385
969
 
1386
- const panResponder = useMemo(
1387
- () =>
1388
- PanResponder.create({
1389
- onStartShouldSetPanResponder: () => canSendInput,
1390
- onMoveShouldSetPanResponder: () => canSendInput,
1391
- onPanResponderGrant: (event) => {
1392
- const point = pointFromEvent(event);
1393
- dragStartRef.current = point ? { x: point.x, y: point.y } : null;
1394
- },
1395
- onPanResponderRelease: finishPointerGesture,
1396
- onPanResponderTerminate: () => {
1397
- dragStartRef.current = null;
1398
- },
1399
- onPanResponderTerminationRequest: () => false,
1400
- }),
1401
- [canSendInput, finishPointerGesture, pointFromEvent],
1402
- );
970
+ const composeOwnershipKey = JSON.stringify([
971
+ viewerToken,
972
+ controlToken,
973
+ state?.sessionId,
974
+ state?.runtimeId,
975
+ state?.bridgeEpoch,
976
+ state?.navigationGeneration,
977
+ state?.viewportGeneration,
978
+ ]);
979
+ const composeDocumentKey = JSON.stringify([
980
+ viewerToken,
981
+ state?.sessionId,
982
+ state?.runtimeId,
983
+ state?.bridgeEpoch,
984
+ state?.navigationGeneration,
985
+ state?.viewportGeneration,
986
+ ]);
987
+ // Touch-first surfaces (native and compact web) get the explicit draft; desktop web types on the canvas.
988
+ const composeAvailable = layout.platform !== "web" || layout.compact;
989
+ const requestCompose = (kind: "compose" | "commit") => {
990
+ if (!canSendInput) return;
991
+ nextComposeRequest.current += 1;
992
+ closeToolbarMenu(false);
993
+ setComposeRequest({ id: nextComposeRequest.current, kind, ownershipKey: composeOwnershipKey });
994
+ };
995
+ const composeRequestHandled = useCallback((id: number) => {
996
+ setComposeRequest((current) => (current?.id === id ? null : current));
997
+ }, []);
1403
998
 
1404
999
  const handleCanvasLayout = useCallback((event: LayoutChangeEvent) => {
1405
1000
  const { width, height } = event.nativeEvent.layout;
@@ -1411,11 +1006,136 @@ export function SharedBrowserPanel({
1411
1006
  const takeControl = useCallback(
1412
1007
  (takeover: boolean) => {
1413
1008
  if (!viewerToken || acquireMutation.isPending) return;
1414
- lifecycle.bump();
1009
+ inputLifecycle.bump();
1415
1010
  acquireMutation.mutate({ viewerToken, takeover });
1416
1011
  },
1417
1012
  [acquireMutation, viewerToken],
1418
1013
  );
1014
+
1015
+ /** Ask for authority before running an action, preserving its exact tab and page basis. */
1016
+ const requestControlled = (label: string, run: () => void, needsFrame = false) => {
1017
+ if (anyMutationPending || controlRequestPendingRef.current) return;
1018
+ if (controlContext()) {
1019
+ run();
1020
+ return;
1021
+ }
1022
+ const current = stateRef.current;
1023
+ const viewer = activeViewerTokenRef.current;
1024
+ if (!current || !viewer || (selectedTabId && current.tabId !== selectedTabId) || viewingExpired)
1025
+ return;
1026
+ setControlRequest({
1027
+ label,
1028
+ basis: controlRequestBasis(
1029
+ panelScope,
1030
+ viewer,
1031
+ current,
1032
+ controllerObservationRevision.current,
1033
+ ),
1034
+ needsFrame,
1035
+ run,
1036
+ });
1037
+ };
1038
+
1039
+ const requestIsCurrent = useCallback(
1040
+ (basis: ControlRequestBasis) =>
1041
+ !closedViewRef.current &&
1042
+ (!selectedTabIdRef.current || selectedTabIdRef.current === basis.tabId) &&
1043
+ isControlRequestCurrent(
1044
+ basis,
1045
+ panelScopeRef.current,
1046
+ activeViewerTokenRef.current,
1047
+ stateRef.current,
1048
+ controllerObservationRevision.current,
1049
+ ),
1050
+ [],
1051
+ );
1052
+
1053
+ /** Acquire once, then let the committed controlled render run the queued action once. */
1054
+ const confirmControlRequest = async () => {
1055
+ const request = controlRequest;
1056
+ if (!request || controlRequestPendingRef.current) return;
1057
+ const current = stateRef.current;
1058
+ if (!requestIsCurrent(request.basis)) {
1059
+ setControlRequest(null);
1060
+ setOperationError("The browser changed. Select the action again.");
1061
+ return;
1062
+ }
1063
+
1064
+ controlRequestPendingRef.current = true;
1065
+ setControlRequestPending(true);
1066
+ setOperationError(null);
1067
+ try {
1068
+ inputLifecycle.bump();
1069
+ const result = await acquireControl({
1070
+ viewerToken: request.basis.viewerToken,
1071
+ takeover: current?.controller === "other",
1072
+ });
1073
+ if (!mountedRef.current || !requestIsCurrent(request.basis)) {
1074
+ await releaseControl({
1075
+ viewerToken: request.basis.viewerToken,
1076
+ controlToken: result.controlToken,
1077
+ }).catch(() => undefined);
1078
+ return;
1079
+ }
1080
+ setControlRequest(null);
1081
+ if (!acceptState(result.state)) {
1082
+ await releaseControl({
1083
+ viewerToken: request.basis.viewerToken,
1084
+ controlToken: result.controlToken,
1085
+ }).catch(() => undefined);
1086
+ setOperationError("The browser changed. Select the action again.");
1087
+ return;
1088
+ }
1089
+ setControlToken(result.controlToken);
1090
+ refreshCapture();
1091
+ if (
1092
+ requestIsCurrent(request.basis) &&
1093
+ isControlRequestCurrent(
1094
+ request.basis,
1095
+ panelScopeRef.current,
1096
+ activeViewerTokenRef.current,
1097
+ result.state,
1098
+ controllerObservationRevision.current,
1099
+ )
1100
+ ) {
1101
+ setPendingControlAction(request);
1102
+ } else {
1103
+ setOperationError("The browser changed. Select the action again.");
1104
+ }
1105
+ } catch (error) {
1106
+ setControlRequest(null);
1107
+ mutationFailed(error);
1108
+ } finally {
1109
+ controlRequestPendingRef.current = false;
1110
+ if (mountedRef.current) setControlRequestPending(false);
1111
+ }
1112
+ };
1113
+
1114
+ useEffect(() => {
1115
+ if (!pendingControlAction || controlRequestPending) return;
1116
+ if (!requestIsCurrent(pendingControlAction.basis)) {
1117
+ setPendingControlAction(null);
1118
+ setOperationError("The browser changed. Select the action again.");
1119
+ return;
1120
+ }
1121
+ if (
1122
+ !canControl ||
1123
+ (pendingControlAction.needsFrame &&
1124
+ (!canSendInput || admittedPaint?.epoch !== inputLifecycle.epoch))
1125
+ )
1126
+ return;
1127
+ setPendingControlAction(null);
1128
+ pendingControlAction.run();
1129
+ }, [
1130
+ pendingControlAction,
1131
+ controlRequestPending,
1132
+ canControl,
1133
+ canSendInput,
1134
+ admittedPaint,
1135
+ state,
1136
+ requestIsCurrent,
1137
+ ]);
1138
+
1419
1139
  const release = useCallback(() => {
1420
1140
  const viewer = activeViewerTokenRef.current;
1421
1141
  if (
@@ -1426,7 +1146,7 @@ export function SharedBrowserPanel({
1426
1146
  ) {
1427
1147
  return;
1428
1148
  }
1429
- lifecycle.bump();
1149
+ inputLifecycle.bump();
1430
1150
  releaseMutation.mutate({ viewerToken: viewer, controlToken });
1431
1151
  }, [controlToken, releaseMutation]);
1432
1152
 
@@ -1440,11 +1160,11 @@ export function SharedBrowserPanel({
1440
1160
  setOperationError("Enter an address to navigate.");
1441
1161
  return;
1442
1162
  }
1443
- lifecycle.bump();
1163
+ inputLifecycle.bump();
1444
1164
  navigateMutation.mutate({ ...context, action: { kind: "goto", url: nextUrl } });
1445
1165
  return;
1446
1166
  }
1447
- lifecycle.bump();
1167
+ inputLifecycle.bump();
1448
1168
  navigateMutation.mutate({ ...context, action: { kind: action } });
1449
1169
  },
1450
1170
  [navigateMutation, requireControlContext],
@@ -1468,46 +1188,116 @@ export function SharedBrowserPanel({
1468
1188
  );
1469
1189
  return;
1470
1190
  }
1471
- lifecycle.bump();
1191
+ inputLifecycle.bump();
1192
+ setFillViewportScope(null);
1472
1193
  resizeMutation.mutate({ ...context, viewport: { width, height } });
1473
1194
  }, [requireControlContext, resizeMutation, viewportHeight, viewportWidth]);
1474
1195
 
1196
+ const fillViewportLimitDensity = densityMode === "auto" ? 1 : (state?.captureScale ?? 1);
1197
+ const fillTarget = getFillViewportResolution(containerSize, fillViewportLimitDensity);
1198
+
1199
+ /** Enable following and resize using current geometry after control acquisition. */
1200
+ const fillViewport = useCallback(() => {
1201
+ const context = requireControlContext();
1202
+ if (!context || resizeMutation.isPending) return;
1203
+ const viewport = fillTarget;
1204
+ if (!viewport) {
1205
+ setOperationError(
1206
+ "The visible browser area is outside the supported resolution range at the current capture density.",
1207
+ );
1208
+ return;
1209
+ }
1210
+ inputLifecycle.bump();
1211
+ setFillViewportScope(panelScope);
1212
+ setScaleMode("fit");
1213
+ const captureDensity =
1214
+ densityMode === "auto"
1215
+ ? getAutomaticCaptureDensity(viewport, containerSize, screenPixelRatio, "fit")
1216
+ : null;
1217
+ resizeMutation.mutate({
1218
+ ...context,
1219
+ viewport,
1220
+ preserveEmulation: true,
1221
+ ...(captureDensity === null ? {} : { captureDensity }),
1222
+ });
1223
+ }, [
1224
+ requireControlContext,
1225
+ resizeMutation,
1226
+ containerSize,
1227
+ inputLifecycle,
1228
+ panelScope,
1229
+ fillTarget,
1230
+ densityMode,
1231
+ screenPixelRatio,
1232
+ ]);
1233
+
1234
+ useBrowserFillViewport({
1235
+ identity: mutationIdentity,
1236
+ enabled: fillViewportEnabled,
1237
+ canResize: canControl && !activeInput && !anyMutationPending && state?.status === "ready",
1238
+ target: fillTarget,
1239
+ viewport: state?.viewport ?? null,
1240
+ apply: () => {
1241
+ if (!activeInputRef.current) fillViewport();
1242
+ },
1243
+ });
1244
+
1245
+ const followingNeedsResize =
1246
+ fillViewportEnabled &&
1247
+ (fillTarget?.width !== state?.viewport.width || fillTarget?.height !== state?.viewport.height);
1248
+ useBrowserAutoCaptureDensity({
1249
+ identity: mutationIdentity,
1250
+ enabled: automaticDensityEnabled,
1251
+ canChange:
1252
+ canControl &&
1253
+ !activeInput &&
1254
+ !anyMutationPending &&
1255
+ !followingNeedsResize &&
1256
+ state?.status === "ready",
1257
+ target: state
1258
+ ? getAutomaticCaptureDensity(state.viewport, containerSize, screenPixelRatio, scaleMode)
1259
+ : null,
1260
+ currentDensity: state?.captureScale ?? 1,
1261
+ apply: (density) => {
1262
+ if (!activeInputRef.current) densityControl.select(density);
1263
+ },
1264
+ });
1265
+
1475
1266
  const selectDevicePreset = useCallback(
1476
1267
  (presetId: DevicePresetId) => {
1477
1268
  const context = requireControlContext();
1478
1269
  if (!context || deviceMutation.isPending) return;
1479
- lifecycle.bump();
1270
+ inputLifecycle.bump();
1271
+ setFillViewportScope(null);
1480
1272
  setDevicePickerOpen(false);
1481
1273
  deviceMutation.mutate({ ...context, presetId });
1482
1274
  },
1483
1275
  [deviceMutation, requireControlContext],
1484
1276
  );
1485
1277
 
1486
- const scroll = useCallback(
1487
- (deltaX: number, deltaY: number) => {
1488
- const viewport = stateRef.current?.viewport;
1489
- if (!displayRect || !viewport) return;
1490
- const previous = lastPointRef.current;
1491
- const x = previous && previous.x <= displayRect.width ? previous.x : displayRect.width / 2;
1492
- const y = previous && previous.y <= displayRect.height ? previous.y : displayRect.height / 2;
1493
- sendEvent({
1494
- kind: "scroll",
1495
- point: toViewportPoint({ x, y }, displayRect, viewport),
1496
- deltaX,
1497
- deltaY,
1498
- });
1278
+ const applyEmulationSelection = useCallback(
1279
+ (selection: EmulationSelection) => {
1280
+ const context = requireControlContext();
1281
+ if (!context || anyMutationPending) return;
1282
+ inputLifecycle.bump();
1283
+ deviceMutation.mutate({ ...context, ...selection });
1499
1284
  },
1500
- [displayRect, sendEvent],
1285
+ [requireControlContext, anyMutationPending, inputLifecycle, deviceMutation],
1501
1286
  );
1502
-
1503
- const sendText = useCallback(() => {
1504
- if (!typeDraft || inputMutation.isPending) return;
1505
- sendEvent({ kind: "type", text: typeDraft });
1506
- }, [inputMutation.isPending, sendEvent, typeDraft]);
1287
+ const emulation = useBrowserEmulationMode({
1288
+ identity: JSON.stringify([host.id, workspaceId]),
1289
+ platform: layout.platform,
1290
+ state,
1291
+ canControl,
1292
+ pending: anyMutationPending || Boolean(pendingControlAction),
1293
+ apply: applyEmulationSelection,
1294
+ });
1507
1295
 
1508
1296
  const reconnect = useCallback(() => {
1509
1297
  if (attachQuery.isFetching) return;
1510
- lifecycle.bump();
1298
+ inputLifecycle.bump();
1299
+ // Immediately revoke local input while the viewing-only reattachment awaits.
1300
+ activeViewerTokenRef.current = null;
1511
1301
  setReconnecting(true);
1512
1302
  setControlToken(null);
1513
1303
  setOperationError(null);
@@ -1516,7 +1306,150 @@ export function SharedBrowserPanel({
1516
1306
  });
1517
1307
  }, [attachQuery.isFetching, attachQuery.refetch]);
1518
1308
 
1519
- const connectionError = attachQuery.error ?? captureQuery.error;
1309
+ /** Add a page to the shared profile and select it only for this panel. */
1310
+ const createBrowserTab = async () => {
1311
+ const viewerToken = activeViewerTokenRef.current;
1312
+ if (!viewerToken || tabMutationPending) return;
1313
+ setTabMutationPending(true);
1314
+ setOperationError(null);
1315
+ try {
1316
+ const created = await createTab({ viewerToken });
1317
+ // A pre-creation list can otherwise undo selection through the missing-tab
1318
+ // fallback. Cancel its receipt and include the confirmed blank page first.
1319
+ await queryClient.cancelQueries({ queryKey: tabsQueryKey, exact: true });
1320
+ if (
1321
+ !mountedRef.current ||
1322
+ panelScopeRef.current !== panelScope ||
1323
+ activeViewerTokenRef.current !== viewerToken
1324
+ ) {
1325
+ return;
1326
+ }
1327
+ queryClient.setQueryData<Awaited<ReturnType<typeof listTabs>>>(tabsQueryKey, (previous) => {
1328
+ if (previous?.tabs.some((tab) => tab.id === created.tabId)) return previous;
1329
+ return {
1330
+ tabs: [
1331
+ ...(previous?.tabs ?? []),
1332
+ {
1333
+ id: created.tabId,
1334
+ title: "",
1335
+ url: "about:blank",
1336
+ viewerCount: 0,
1337
+ controllerLabel: null,
1338
+ },
1339
+ ],
1340
+ };
1341
+ });
1342
+ setSelectedTabId(created.tabId);
1343
+ void tabsQuery.refetch();
1344
+ } catch (error) {
1345
+ setOperationError(errorMessage(error));
1346
+ } finally {
1347
+ setTabMutationPending(false);
1348
+ }
1349
+ };
1350
+
1351
+ /** Close only the controlled page; other tabs and their viewers stay attached. */
1352
+ const confirmCloseSelectedTab = async () => {
1353
+ const viewerToken = activeViewerTokenRef.current;
1354
+ const tabId = selectedTabId ?? stateRef.current?.tabId;
1355
+ if (!viewerToken || !controlToken || !tabId || tabMutationPending) return;
1356
+ setTabMutationPending(true);
1357
+ setOperationError(null);
1358
+ try {
1359
+ await closeTab({ viewerToken, controlToken, tabId });
1360
+ setTabCloseConfirmationOpen(false);
1361
+ const fallback = tabsQuery.data?.tabs.find((tab) => tab.id !== tabId);
1362
+ setSelectedTabId(fallback?.id ?? null);
1363
+ void tabsQuery.refetch();
1364
+ } catch (error) {
1365
+ setOperationError(errorMessage(error));
1366
+ } finally {
1367
+ setTabMutationPending(false);
1368
+ }
1369
+ };
1370
+
1371
+ /** Confirmed close ends every viewer's runtime and immediately drops this panel's media. */
1372
+ const confirmCloseBrowser = async () => {
1373
+ const current = stateRef.current;
1374
+ const viewerToken = activeViewerTokenRef.current;
1375
+ if (!current?.runtimeId || !viewerToken || !controlToken || closePending) return;
1376
+ setClosePending(true);
1377
+ setOperationError(null);
1378
+ try {
1379
+ await closeBrowser({
1380
+ viewerToken,
1381
+ controlToken,
1382
+ sessionId: current.sessionId,
1383
+ runtimeId: current.runtimeId,
1384
+ });
1385
+ if (panelScopeRef.current !== panelScope) return;
1386
+ inputLifecycle.bump();
1387
+ activeViewerTokenRef.current = null;
1388
+ stateRef.current = null;
1389
+ frameRef.current = null;
1390
+ reset();
1391
+ setState(null);
1392
+ setControlToken(null);
1393
+ setBrowserClosed(true);
1394
+ setCloseConfirmationOpen(false);
1395
+ } catch (error) {
1396
+ setOperationError(errorMessage(error));
1397
+ } finally {
1398
+ setClosePending(false);
1399
+ }
1400
+ };
1401
+
1402
+ // Queued actions always resolve through this render's scoped mutation callbacks.
1403
+ // A callback captured before acquisition must never reuse its old control identity.
1404
+ controlledActionsRef.current = {
1405
+ navigate,
1406
+ applyViewport,
1407
+ fillViewport,
1408
+ selectDevicePreset,
1409
+ changeDensity: (value) => {
1410
+ setDensitySelection({ scope: panelScope, mode: value });
1411
+ setAutomaticDensityFailureScope(null);
1412
+ if (value !== "auto") densityControl.select(value);
1413
+ },
1414
+ toggleEmulation: emulation.toggle,
1415
+ sendKey: (key) => sendEvent({ kind: "key", key }),
1416
+ openCompose: requestCompose,
1417
+ closeTab: () => setTabCloseConfirmationOpen(true),
1418
+ closeBrowser: () => setCloseConfirmationOpen(true),
1419
+ confirmCloseTab: () => void confirmCloseSelectedTab(),
1420
+ confirmCloseBrowser: () => void confirmCloseBrowser(),
1421
+ };
1422
+
1423
+ /** Explicit user action is the only way to clear the server's closed fence. */
1424
+ const reopenClosedBrowser = async () => {
1425
+ if (reopenPending) return;
1426
+ setReopenPending(true);
1427
+ setOperationError(null);
1428
+ try {
1429
+ await reopenBrowser({ workspaceId });
1430
+ if (panelScopeRef.current !== panelScope) return;
1431
+ setBrowserClosed(false);
1432
+ reconnect();
1433
+ } catch (error) {
1434
+ setOperationError(errorMessage(error));
1435
+ } finally {
1436
+ setReopenPending(false);
1437
+ }
1438
+ };
1439
+
1440
+ useBrowserViewerRecovery({
1441
+ identity: JSON.stringify([host.id, workspaceId]),
1442
+ viewerToken,
1443
+ failedViewerToken: videoViewerExpired ? video.errorViewerToken : viewerToken,
1444
+ captureError: videoViewerExpired ? video.error : captureQuery.error,
1445
+ pending: reconnecting || attachQuery.isFetching,
1446
+ reconnect,
1447
+ });
1448
+
1449
+ // Expiry belongs to the viewing-only recovery path, not an action failure.
1450
+ // A failed reattachment still surfaces its attachQuery error and manual retry.
1451
+ const connectionError =
1452
+ attachQuery.error ?? (viewingExpired ? null : (captureQuery.error ?? video.error));
1520
1453
  const recoveryError =
1521
1454
  state?.recoveryState === "runtime-unavailable"
1522
1455
  ? (state.error ?? "Browser runtime unavailable.")
@@ -1525,7 +1458,8 @@ export function SharedBrowserPanel({
1525
1458
  operationError ??
1526
1459
  recoveryError ??
1527
1460
  state?.error ??
1528
- (connectionError ? errorMessage(connectionError) : null);
1461
+ (connectionError ? errorMessage(connectionError) : null) ??
1462
+ (tabsQuery.error ? errorMessage(tabsQuery.error) : null);
1529
1463
  const statusColor =
1530
1464
  state?.status === "ready"
1531
1465
  ? theme.colors.statusSuccess
@@ -1534,27 +1468,33 @@ export function SharedBrowserPanel({
1534
1468
  : theme.colors.statusWarning;
1535
1469
  const statusLabel =
1536
1470
  state?.status === "ready" ? "Ready" : state?.status === "error" ? "Error" : "Starting";
1537
- const leaseExpiry = state?.controllerExpiresAt
1538
- ? new Date(state.controllerExpiresAt).toLocaleTimeString()
1539
- : null;
1540
- const leaseDetail = leaseExpiry ? ` · lease until ${leaseExpiry}` : "";
1541
- const controllerLabel =
1542
- state?.controller === "self"
1543
- ? controlToken
1544
- ? `You have control${leaseDetail}`
1545
- : "Control token unavailable"
1546
- : state?.controller === "other"
1547
- ? `${state.controllerLabel ?? "Another viewer"} has control${leaseDetail}`
1548
- : "Observe-only · no controller";
1549
1471
  const activeDevicePreset = state?.devicePresetId
1550
1472
  ? DEVICE_PRESETS.find(({ id }) => id === state.devicePresetId)
1551
1473
  : null;
1552
- const deviceLabel = activeDevicePreset?.label ?? "Custom display";
1553
- const transportLabel = currentFrame?.transport === "cdp-screencast" ? "CDP" : "fallback";
1554
- const frameSummary = currentFrame
1474
+ const selectedResolutionPresetId = fillViewportEnabled ? null : matchingResolutionPresetId(state);
1475
+ const fillViewportResolution = fillTarget;
1476
+ const fillViewportDetail = fillViewportResolution
1477
+ ? `${fillViewportResolution.width} × ${fillViewportResolution.height}`
1478
+ : `Requires ${MIN_VIEWPORT.width}–${Math.floor(MAX_VIEWPORT.width / fillViewportLimitDensity)} × ${MIN_VIEWPORT.height}–${Math.floor(MAX_VIEWPORT.height / fillViewportLimitDensity)} at current capture density`;
1479
+ let deviceLabel = `${emulation.mode === "mobile" ? "Mobile" : "Desktop"} · custom display`;
1480
+ if (fillViewportEnabled) {
1481
+ deviceLabel = "Fill viewport";
1482
+ } else if (selectedResolutionPresetId) {
1483
+ deviceLabel = activeDevicePreset?.label ?? "Custom display";
1484
+ }
1485
+ const transportLabel =
1486
+ video.front && video.fallbackRevision
1487
+ ? "Updating video"
1488
+ : video.front
1489
+ ? "Video"
1490
+ : frame?.transport === "cdp-screencast"
1491
+ ? "CDP"
1492
+ : "fallback";
1493
+ const summaryFrame = video.front?.frame ?? currentFrame;
1494
+ const frameSummary = summaryFrame
1555
1495
  ? layout.compact
1556
- ? `${currentFrame.width}×${currentFrame.height} · ${transportLabel}`
1557
- : `${currentFrame.width} × ${currentFrame.height} · ${Math.ceil(currentFrame.byteLength / BYTES_PER_KIBIBYTE)} KB · ${transportLabel} · ${deviceLabel}`
1496
+ ? `${summaryFrame.width}×${summaryFrame.height} · ${transportLabel}`
1497
+ : `${summaryFrame.width} × ${summaryFrame.height} · ${Math.ceil((video.front ? (video.front.dataBase64.length * 3) / 4 : (frame?.byteLength ?? 0)) / BYTES_PER_KIBIBYTE)} KB · ${transportLabel} · ${deviceLabel}`
1558
1498
  : state
1559
1499
  ? `${state.viewport.width} × ${state.viewport.height} canonical`
1560
1500
  : "No frame";
@@ -1567,6 +1507,7 @@ export function SharedBrowserPanel({
1567
1507
  theme={theme}
1568
1508
  label="Release"
1569
1509
  icon="LogOut"
1510
+ iconOnly
1570
1511
  disabled={anyMutationPending}
1571
1512
  onPress={release}
1572
1513
  />
@@ -1578,6 +1519,7 @@ export function SharedBrowserPanel({
1578
1519
  theme={theme}
1579
1520
  label="Take over"
1580
1521
  icon="Crown"
1522
+ iconOnly
1581
1523
  danger
1582
1524
  disabled={!viewerToken || anyMutationPending}
1583
1525
  onPress={() => takeControl(true)}
@@ -1590,6 +1532,7 @@ export function SharedBrowserPanel({
1590
1532
  theme={theme}
1591
1533
  label={state?.controller === "self" ? "Reacquire" : "Take control"}
1592
1534
  icon="MousePointer2"
1535
+ iconOnly
1593
1536
  primary
1594
1537
  disabled={!viewerToken || anyMutationPending}
1595
1538
  onPress={() => takeControl(false)}
@@ -1597,17 +1540,6 @@ export function SharedBrowserPanel({
1597
1540
  );
1598
1541
  }
1599
1542
 
1600
- const imageStyle = displayRect
1601
- ? [
1602
- styles.frame,
1603
- {
1604
- left: displayRect.x,
1605
- top: displayRect.y,
1606
- width: displayRect.width,
1607
- height: displayRect.height,
1608
- },
1609
- ]
1610
- : styles.frame;
1611
1543
  const interactionStyle = displayRect
1612
1544
  ? [
1613
1545
  styles.interactionLayer,
@@ -1620,8 +1552,69 @@ export function SharedBrowserPanel({
1620
1552
  ]
1621
1553
  : styles.interactionLayer;
1622
1554
 
1555
+ if (closedView) {
1556
+ return (
1557
+ <View style={styles.screen}>
1558
+ <CanvasPlaceholder
1559
+ styles={styles}
1560
+ theme={theme}
1561
+ title="Shared browser closed"
1562
+ detail="The browser was closed for everyone. Its saved site data is available when you reopen it."
1563
+ >
1564
+ <ControlButton
1565
+ styles={styles}
1566
+ theme={theme}
1567
+ label={reopenPending ? "Opening…" : "Open shared browser"}
1568
+ icon="PanelsTopLeft"
1569
+ primary
1570
+ large
1571
+ disabled={reopenPending}
1572
+ onPress={() => void reopenClosedBrowser()}
1573
+ />
1574
+ </CanvasPlaceholder>
1575
+ {operationError ? (
1576
+ <ErrorNotice styles={styles} theme={theme} message={operationError} />
1577
+ ) : null}
1578
+ </View>
1579
+ );
1580
+ }
1581
+
1623
1582
  return (
1624
- <View style={styles.screen}>
1583
+ <View
1584
+ ref={paneRef}
1585
+ collapsable={false}
1586
+ style={styles.screen}
1587
+ onLayout={(event) => {
1588
+ const { width, height } = event.nativeEvent.layout;
1589
+ setPaneSize((previous) =>
1590
+ previous.width === width && previous.height === height ? previous : { width, height },
1591
+ );
1592
+ }}
1593
+ >
1594
+ <BrowserTabs
1595
+ styles={styles}
1596
+ theme={theme}
1597
+ tabs={tabsQuery.data?.tabs ?? []}
1598
+ selectedId={selectedTabId ?? state?.tabId ?? null}
1599
+ selectedState={state}
1600
+ statusColor={statusColor}
1601
+ statusLabel={statusLabel}
1602
+ controlAction={controlAction}
1603
+ canCreate={
1604
+ Boolean(viewerToken) &&
1605
+ !tabMutationPending &&
1606
+ (tabsQuery.data?.tabs.length ?? 0) < MAX_BROWSER_TABS
1607
+ }
1608
+ canClose={
1609
+ Boolean(viewerToken) && !tabMutationPending && (tabsQuery.data?.tabs.length ?? 0) > 1
1610
+ }
1611
+ onSelect={setSelectedTabId}
1612
+ onCreate={() => void createBrowserTab()}
1613
+ onClose={() =>
1614
+ requestControlled("close this tab", () => controlledActionsRef.current?.closeTab())
1615
+ }
1616
+ />
1617
+
1625
1618
  <View style={styles.chrome}>
1626
1619
  <View style={styles.addressRow}>
1627
1620
  <ChromeIconButton
@@ -1629,24 +1622,34 @@ export function SharedBrowserPanel({
1629
1622
  theme={theme}
1630
1623
  label="Back"
1631
1624
  icon="ArrowLeft"
1632
- disabled={!canControl || !state?.canGoBack || navigateMutation.isPending}
1633
- onPress={() => navigate("back")}
1625
+ disabled={!viewerToken || !state?.canGoBack || anyMutationPending}
1626
+ onPress={() =>
1627
+ requestControlled("go back", () => controlledActionsRef.current?.navigate("back"))
1628
+ }
1634
1629
  />
1635
1630
  <ChromeIconButton
1636
1631
  styles={styles}
1637
1632
  theme={theme}
1638
1633
  label="Forward"
1639
1634
  icon="ArrowRight"
1640
- disabled={!canControl || !state?.canGoForward || navigateMutation.isPending}
1641
- onPress={() => navigate("forward")}
1635
+ disabled={!viewerToken || !state?.canGoForward || anyMutationPending}
1636
+ onPress={() =>
1637
+ requestControlled("go forward", () =>
1638
+ controlledActionsRef.current?.navigate("forward"),
1639
+ )
1640
+ }
1642
1641
  />
1643
1642
  <ChromeIconButton
1644
1643
  styles={styles}
1645
1644
  theme={theme}
1646
1645
  label="Reload"
1647
1646
  icon="RotateCw"
1648
- disabled={!canControl || navigateMutation.isPending}
1649
- onPress={() => navigate("reload")}
1647
+ disabled={!viewerToken || anyMutationPending}
1648
+ onPress={() =>
1649
+ requestControlled("reload the page", () =>
1650
+ controlledActionsRef.current?.navigate("reload"),
1651
+ )
1652
+ }
1650
1653
  />
1651
1654
  <Field
1652
1655
  styles={styles}
@@ -1654,48 +1657,65 @@ export function SharedBrowserPanel({
1654
1657
  value={addressDraft}
1655
1658
  accessibilityLabel="Browser address"
1656
1659
  placeholder="Enter a URL"
1657
- editable={canControl && !navigateMutation.isPending}
1660
+ editable={Boolean(viewerToken) && !navigateMutation.isPending}
1661
+ dimWhenReadOnly={false}
1662
+ keyboardType="url"
1658
1663
  maxLength={MAX_URL_LENGTH}
1659
1664
  returnKeyType="go"
1660
1665
  style={[styles.addressInput, styles.chromeAddressInput]}
1661
1666
  onChangeText={setAddressDraft}
1662
1667
  onFocus={() => setAddressFocused(true)}
1663
1668
  onBlur={() => setAddressFocused(false)}
1664
- onSubmit={() => navigate("goto", addressDraft)}
1669
+ onSubmit={() => {
1670
+ const target = addressDraft;
1671
+ requestControlled("open this address", () =>
1672
+ controlledActionsRef.current?.navigate("goto", target),
1673
+ );
1674
+ }}
1665
1675
  />
1676
+ <View ref={displayAnchorRef} collapsable={false}>
1677
+ <ChromeIconButton
1678
+ styles={styles}
1679
+ theme={theme}
1680
+ label="Display options"
1681
+ icon="Monitor"
1682
+ selected={toolbarMenu === "display"}
1683
+ expanded={toolbarMenu === "display"}
1684
+ onPress={() => toggleToolbarMenu("display")}
1685
+ />
1686
+ </View>
1666
1687
  <ChromeIconButton
1667
1688
  styles={styles}
1668
1689
  theme={theme}
1669
- label={`Device: ${deviceLabel}`}
1670
- icon={activeDevicePreset?.isMobile ? "Smartphone" : "Monitor"}
1671
- selected={Boolean(activeDevicePreset)}
1672
- disabled={!canControl || deviceMutation.isPending}
1673
- onPress={() => setDevicePickerOpen(true)}
1690
+ label={`${emulation.mode === "mobile" ? "Disable" : "Enable"} mobile emulation`}
1691
+ icon="Smartphone"
1692
+ selected={emulation.mode === "mobile"}
1693
+ disabled={!viewerToken || anyMutationPending}
1694
+ onPress={() =>
1695
+ requestControlled("change mobile emulation", () =>
1696
+ controlledActionsRef.current?.toggleEmulation(),
1697
+ )
1698
+ }
1674
1699
  />
1700
+ <View ref={actionsAnchorRef} collapsable={false}>
1701
+ <ChromeIconButton
1702
+ styles={styles}
1703
+ theme={theme}
1704
+ label="Browser menu"
1705
+ icon="EllipsisVertical"
1706
+ selected={toolbarMenu === "actions"}
1707
+ expanded={toolbarMenu === "actions"}
1708
+ onPress={() => toggleToolbarMenu("actions")}
1709
+ />
1710
+ </View>
1675
1711
  </View>
1676
1712
  </View>
1677
1713
 
1678
- <View style={styles.statusRow}>
1679
- <View style={styles.statusSummary}>
1680
- <View style={[styles.statusDot, { backgroundColor: statusColor }]} />
1681
- <Text style={styles.statusText}>{state ? statusLabel : "Connecting"}</Text>
1682
- <Text style={styles.mutedText}>
1683
- {state
1684
- ? `${state.viewerCount} viewer${state.viewerCount === 1 ? "" : "s"}`
1685
- : viewerLabel}
1686
- </Text>
1687
- <Text numberOfLines={1} style={styles.controllerText}>
1688
- {state ? controllerLabel : "Attaching to workspace browser"}
1689
- </Text>
1690
- </View>
1691
- <View style={styles.actionRow}>{controlAction}</View>
1692
- </View>
1693
-
1694
- {runtimeNotice || visibleError ? (
1714
+ {runtimeNotice || visibleError || state?.notice ? (
1695
1715
  <ErrorNotice
1696
1716
  styles={styles}
1697
1717
  theme={theme}
1698
- message={runtimeNotice ?? visibleError ?? ""}
1718
+ message={runtimeNotice ?? visibleError ?? state?.notice ?? ""}
1699
1719
  action={connectionError || reconnecting ? "Reconnect" : undefined}
1700
1720
  onAction={connectionError || reconnecting ? reconnect : undefined}
1701
1721
  actionDisabled={attachQuery.isFetching}
@@ -1703,31 +1723,64 @@ export function SharedBrowserPanel({
1703
1723
  ) : null}
1704
1724
 
1705
1725
  <View style={styles.canvasShell}>
1706
- <View style={styles.canvas} onLayout={handleCanvasLayout}>
1707
- {frameUri && displayRect && !imageError ? (
1708
- <>
1709
- <Image
1710
- accessibilityLabel={
1711
- state?.title ? `Shared browser: ${state.title}` : "Shared browser frame"
1712
- }
1713
- accessibilityRole="image"
1714
- onError={() => {
1715
- lifecycle.frame = null;
1716
- setImageError(true);
1717
- void captureQuery.refetch({ cancelRefetch: false });
1718
- }}
1719
- resizeMode="contain"
1720
- source={{ uri: frameUri }}
1721
- style={imageStyle}
1726
+ <BrowserCanvasViewport
1727
+ mode={scaleMode}
1728
+ style={styles.canvas}
1729
+ contentSize={canvasLayout?.contentSize ?? containerSize}
1730
+ localPanEnabled={layout.platform === "web" || !canControl}
1731
+ onLayout={handleCanvasLayout}
1732
+ >
1733
+ {buffer.layers.map((layer, slot) => {
1734
+ if (!layer) return null;
1735
+ const rect = getBrowserCanvasLayout(
1736
+ containerSize,
1737
+ layer.candidate.frame,
1738
+ layer.candidate.viewport ?? state?.viewport ?? layer.candidate.frame,
1739
+ scaleMode,
1740
+ )?.frameRect;
1741
+ if (!rect) return null;
1742
+ const visible = slot === buffer.front;
1743
+ return (
1744
+ <BrowserFrameImage
1745
+ key={layer.ticket}
1746
+ ticket={layer.ticket}
1747
+ visible={visible}
1748
+ label={state?.title ? `Shared browser: ${state.title}` : "Shared browser frame"}
1749
+ settled={settled}
1750
+ retry={retryFrameCapture}
1751
+ source={layer.source}
1752
+ x={rect.x}
1753
+ y={rect.y}
1754
+ width={rect.width}
1755
+ height={rect.height}
1756
+ cornerRadius={layout.compact ? DIMENSION.screenRadius - SPACE.xs : 0}
1722
1757
  />
1723
- <View
1724
- {...panResponder.panHandlers}
1725
- accessible={false}
1726
- pointerEvents={canSendInput ? "auto" : "none"}
1727
- style={interactionStyle}
1728
- />
1729
- </>
1730
- ) : imageError ? (
1758
+ );
1759
+ })}
1760
+ {video.supported ? (
1761
+ <BrowserVideoSurface
1762
+ canvasRef={video.canvasRef}
1763
+ style={{
1764
+ position: "absolute",
1765
+ left: displayRect?.x ?? 0,
1766
+ top: displayRect?.y ?? 0,
1767
+ width: displayRect?.width ?? containerSize.width,
1768
+ height: displayRect?.height ?? containerSize.height,
1769
+ opacity: video.front ? 1 : 0,
1770
+ overflow: "hidden",
1771
+ borderRadius: layout.compact ? DIMENSION.screenRadius - SPACE.xs : 0,
1772
+ }}
1773
+ />
1774
+ ) : null}
1775
+ {currentFrame && displayRect ? (
1776
+ <View
1777
+ ref={canvasInput.canvasRef}
1778
+ {...canvasInput.panHandlers}
1779
+ accessible={false}
1780
+ pointerEvents={liveInputEnabled ? "auto" : "none"}
1781
+ style={interactionStyle}
1782
+ />
1783
+ ) : video.front ? null : imageError ? (
1731
1784
  <CanvasPlaceholder
1732
1785
  styles={styles}
1733
1786
  theme={theme}
@@ -1761,7 +1814,7 @@ export function SharedBrowserPanel({
1761
1814
  loading={attachQuery.isPending || reconnecting || captureQuery.isFetching}
1762
1815
  />
1763
1816
  )}
1764
- </View>
1817
+ </BrowserCanvasViewport>
1765
1818
  <View style={styles.canvasFooter}>
1766
1819
  <Text numberOfLines={1} style={styles.canvasFooterText}>
1767
1820
  {state?.title || state?.url || "Shared browser"}
@@ -1772,391 +1825,111 @@ export function SharedBrowserPanel({
1772
1825
  </View>
1773
1826
  </View>
1774
1827
 
1775
- <View style={styles.controls}>
1776
- {layout.compact ? (
1777
- <>
1778
- <View style={styles.mobileRow}>
1779
- <Field
1780
- styles={styles}
1781
- theme={theme}
1782
- value={typeDraft}
1783
- accessibilityLabel="Text to type in the shared browser"
1784
- placeholder={canSendInput ? "Type into the page" : "Take control to type"}
1785
- editable={canSendInput}
1786
- maxLength={MAX_TEXT_LENGTH}
1787
- returnKeyType="send"
1788
- style={[styles.typeInput, styles.buttonLarge]}
1789
- onChangeText={setTypeDraft}
1790
- onSubmit={sendText}
1791
- />
1792
- <ControlButton
1793
- styles={styles}
1794
- theme={theme}
1795
- label="Send"
1796
- icon="Send"
1797
- primary
1798
- large
1799
- disabled={!canSendInput || !typeDraft || inputMutation.isPending}
1800
- onPress={sendText}
1801
- />
1802
- </View>
1803
- <View style={styles.mobileRow}>
1804
- <ControlButton
1805
- styles={styles}
1806
- theme={theme}
1807
- label={swipeMode === "scroll" ? "Swipe scrolls" : "Swipe drags"}
1808
- accessibilityLabel="Pointer and scrolling options"
1809
- icon="MousePointer2"
1810
- large
1811
- fill
1812
- disabled={!canControl}
1813
- onPress={() => setPointerSheetOpen(true)}
1814
- />
1815
- <ControlButton
1816
- styles={styles}
1817
- theme={theme}
1818
- label="Keys"
1819
- icon="Keyboard"
1820
- large
1821
- fill
1822
- disabled={!canSendInput}
1823
- onPress={() => setKeysSheetOpen(true)}
1824
- />
1825
- </View>
1826
- </>
1827
- ) : (
1828
- <>
1829
- <ScrollView
1830
- horizontal
1831
- keyboardShouldPersistTaps="handled"
1832
- showsHorizontalScrollIndicator={false}
1833
- contentContainerStyle={styles.toolbarContent}
1834
- >
1835
- <View style={styles.controlStrip}>
1836
- <Text style={styles.stripLabel}>Tap</Text>
1837
- <ControlButton
1838
- styles={styles}
1839
- theme={theme}
1840
- label="Click"
1841
- selected={interactionMode === "click"}
1842
- disabled={!canSendInput}
1843
- onPress={() => setInteractionMode("click")}
1844
- />
1845
- <ControlButton
1846
- styles={styles}
1847
- theme={theme}
1848
- label="Double"
1849
- selected={interactionMode === "double"}
1850
- disabled={!canSendInput}
1851
- onPress={() => setInteractionMode("double")}
1852
- />
1853
- <ControlButton
1854
- styles={styles}
1855
- theme={theme}
1856
- label="Right"
1857
- selected={interactionMode === "right"}
1858
- disabled={!canSendInput}
1859
- onPress={() => setInteractionMode("right")}
1860
- />
1861
- <View style={styles.separator} />
1862
- <Text style={styles.stripLabel}>Drag</Text>
1863
- <ControlButton
1864
- styles={styles}
1865
- theme={theme}
1866
- label="Scrolls"
1867
- selected={swipeMode === "scroll"}
1868
- disabled={!canControl}
1869
- onPress={() => setSwipeMode("scroll")}
1870
- />
1871
- <ControlButton
1872
- styles={styles}
1873
- theme={theme}
1874
- label="Drags"
1875
- selected={swipeMode === "drag"}
1876
- disabled={!canControl}
1877
- onPress={() => setSwipeMode("drag")}
1878
- />
1879
- <View style={styles.separator} />
1880
- <Text style={styles.stripLabel}>Scroll</Text>
1881
- <ControlButton
1882
- styles={styles}
1883
- theme={theme}
1884
- label="←"
1885
- accessibilityLabel="Scroll left"
1886
- disabled={!canSendInput}
1887
- onPress={() => scroll(-SCROLL_STEP, 0)}
1888
- />
1889
- <ControlButton
1890
- styles={styles}
1891
- theme={theme}
1892
- label="↑"
1893
- accessibilityLabel="Scroll up"
1894
- disabled={!canSendInput}
1895
- onPress={() => scroll(0, -SCROLL_STEP)}
1896
- />
1897
- <ControlButton
1898
- styles={styles}
1899
- theme={theme}
1900
- label="↓"
1901
- accessibilityLabel="Scroll down"
1902
- disabled={!canSendInput}
1903
- onPress={() => scroll(0, SCROLL_STEP)}
1904
- />
1905
- <ControlButton
1906
- styles={styles}
1907
- theme={theme}
1908
- label="→"
1909
- accessibilityLabel="Scroll right"
1910
- disabled={!canSendInput}
1911
- onPress={() => scroll(SCROLL_STEP, 0)}
1912
- />
1913
- </View>
1914
- </ScrollView>
1915
-
1916
- <View style={styles.typeRow}>
1917
- <Field
1918
- styles={styles}
1919
- theme={theme}
1920
- value={typeDraft}
1921
- accessibilityLabel="Text to type in the shared browser"
1922
- placeholder={
1923
- canSendInput
1924
- ? "Type into the focused page element"
1925
- : "Take control and focus a page field"
1926
- }
1927
- editable={canSendInput}
1928
- maxLength={MAX_TEXT_LENGTH}
1929
- returnKeyType="send"
1930
- style={styles.typeInput}
1931
- onChangeText={setTypeDraft}
1932
- onSubmit={sendText}
1933
- />
1934
- <ControlButton
1935
- styles={styles}
1936
- theme={theme}
1937
- label="Send"
1938
- icon="Send"
1939
- primary
1940
- disabled={!canSendInput || !typeDraft || inputMutation.isPending}
1941
- onPress={sendText}
1942
- />
1943
- </View>
1944
- <ScrollView
1945
- horizontal
1946
- keyboardShouldPersistTaps="handled"
1947
- showsHorizontalScrollIndicator={false}
1948
- contentContainerStyle={styles.keysContent}
1949
- >
1950
- <Text style={styles.stripLabel}>Keys</Text>
1951
- {SPECIAL_KEYS.map(({ key, label }) => (
1952
- <ControlButton
1953
- key={key}
1954
- styles={styles}
1955
- theme={theme}
1956
- label={label}
1957
- accessibilityLabel={`Send ${key} key`}
1958
- disabled={!canSendInput}
1959
- onPress={() => sendEvent({ kind: "key", key })}
1960
- />
1961
- ))}
1962
- </ScrollView>
1963
- </>
1964
- )}
1965
- </View>
1828
+ {composeAvailable ? (
1829
+ <ComposeTextControls
1830
+ styles={styles}
1831
+ theme={theme}
1832
+ composeText={canvasInput.composeText}
1833
+ cancelInput={canvasInput.cancel}
1834
+ enabled={canSendInput}
1835
+ ownershipKey={composeOwnershipKey}
1836
+ documentKey={composeDocumentKey}
1837
+ request={composeRequest}
1838
+ onRequestHandled={composeRequestHandled}
1839
+ canRequestControl={Boolean(viewerToken) && !canControl}
1840
+ onRequestControlForCompose={() =>
1841
+ requestControlled(
1842
+ "insert the composed text",
1843
+ () => controlledActionsRef.current?.openCompose("commit"),
1844
+ true,
1845
+ )
1846
+ }
1847
+ />
1848
+ ) : null}
1966
1849
 
1967
1850
  <Modal
1968
- title="Pointer and scrolling"
1969
- icon={<Icon name="MousePointer2" size={18} color={theme.colors.foreground} />}
1970
- open={pointerSheetOpen}
1971
- onOpenChange={setPointerSheetOpen}
1851
+ title="Resolution and quality"
1852
+ icon={<Icon name="Smartphone" size={18} color={theme.colors.foreground} />}
1853
+ open={devicePickerOpen}
1854
+ onOpenChange={setDevicePickerOpen}
1972
1855
  >
1973
1856
  <Modal.Content>
1974
- <View style={styles.sheetContent}>
1975
- <Text style={styles.stripLabel}>Swipe gesture</Text>
1976
- <View style={styles.sheetGrid}>
1977
- <ControlButton
1978
- styles={styles}
1979
- theme={theme}
1980
- label="Scrolls the page"
1981
- large
1982
- fill
1983
- selected={swipeMode === "scroll"}
1984
- disabled={!canControl}
1985
- onPress={() => setSwipeMode("scroll")}
1986
- />
1987
- <ControlButton
1988
- styles={styles}
1989
- theme={theme}
1990
- label="Drags content"
1991
- large
1992
- fill
1993
- selected={swipeMode === "drag"}
1994
- disabled={!canControl}
1995
- onPress={() => setSwipeMode("drag")}
1996
- />
1997
- </View>
1998
- <Text style={styles.stripLabel}>Tap action</Text>
1999
- <View style={styles.sheetGrid}>
2000
- <ControlButton
2001
- styles={styles}
2002
- theme={theme}
2003
- label="Click"
2004
- large
2005
- fill
2006
- selected={interactionMode === "click"}
2007
- disabled={!canControl}
2008
- onPress={() => setInteractionMode("click")}
2009
- />
2010
- <ControlButton
2011
- styles={styles}
2012
- theme={theme}
2013
- label="Double"
2014
- large
2015
- fill
2016
- selected={interactionMode === "double"}
2017
- disabled={!canControl}
2018
- onPress={() => setInteractionMode("double")}
2019
- />
2020
- <ControlButton
2021
- styles={styles}
2022
- theme={theme}
2023
- label="Right"
2024
- large
2025
- fill
2026
- selected={interactionMode === "right"}
2027
- disabled={!canControl}
2028
- onPress={() => setInteractionMode("right")}
2029
- />
2030
- </View>
2031
- <Text style={styles.stripLabel}>Nudge scroll</Text>
2032
- <View style={styles.sheetPad}>
2033
- <ControlButton
2034
- styles={styles}
2035
- theme={theme}
2036
- label="↑"
2037
- accessibilityLabel="Scroll up"
2038
- pad
2039
- disabled={!canSendInput}
2040
- onPress={() => scroll(0, -SCROLL_STEP)}
2041
- />
2042
- <View style={styles.sheetPadRow}>
2043
- <ControlButton
2044
- styles={styles}
2045
- theme={theme}
2046
- label="←"
2047
- accessibilityLabel="Scroll left"
2048
- pad
2049
- disabled={!canSendInput}
2050
- onPress={() => scroll(-SCROLL_STEP, 0)}
2051
- />
2052
- <View style={styles.padSpacer} />
1857
+ <View style={styles.deviceModalContent}>
1858
+ <BrowserResolutionPicker
1859
+ theme={theme}
1860
+ groups={groupResolutionPresets()}
1861
+ selectedPresetId={selectedResolutionPresetId}
1862
+ fillViewportResolution={fillViewportResolution}
1863
+ fillViewportSelected={fillViewportEnabled}
1864
+ fillViewportDetail={fillViewportDetail}
1865
+ onFillViewport={() =>
1866
+ requestControlled("fill the browser viewport", () =>
1867
+ controlledActionsRef.current?.fillViewport(),
1868
+ )
1869
+ }
1870
+ favoritePresetIds={preferences.favoritePresetIds}
1871
+ selectDisabled={!viewerToken || anyMutationPending}
1872
+ favoriteDisabled={preferences.disabled}
1873
+ density={state?.captureScale ?? 1}
1874
+ densityMode={densityMode}
1875
+ viewport={state?.viewport ?? null}
1876
+ onDensityChange={(density) => {
1877
+ requestControlled("change capture density", () =>
1878
+ controlledActionsRef.current?.changeDensity(density),
1879
+ );
1880
+ }}
1881
+ captureQuality={preferences.captureQuality}
1882
+ videoBitrate={preferences.videoBitrate}
1883
+ videoFps={preferences.videoFps}
1884
+ onVideoBitrateChange={preferences.changeVideoBitrate}
1885
+ onVideoFpsChange={preferences.changeVideoFps}
1886
+ onSelect={(id) =>
1887
+ requestControlled("change the browser resolution", () =>
1888
+ controlledActionsRef.current?.selectDevicePreset(id),
1889
+ )
1890
+ }
1891
+ onToggleFavorite={(id) => {
1892
+ void preferences.toggleFavorite(id);
1893
+ }}
1894
+ onQualityChange={(quality) => {
1895
+ void preferences.changeQuality(quality);
1896
+ }}
1897
+ />
1898
+ {preferences.error ? (
1899
+ <View style={styles.controlStrip}>
1900
+ <Text accessibilityRole="alert" style={styles.mutedText}>
1901
+ {preferences.error}
1902
+ </Text>
2053
1903
  <ControlButton
2054
1904
  styles={styles}
2055
1905
  theme={theme}
2056
- label="→"
2057
- accessibilityLabel="Scroll right"
2058
- pad
2059
- disabled={!canSendInput}
2060
- onPress={() => scroll(SCROLL_STEP, 0)}
1906
+ label="Reload preferences"
1907
+ onPress={preferences.reload}
2061
1908
  />
2062
1909
  </View>
2063
- <ControlButton
2064
- styles={styles}
2065
- theme={theme}
2066
- label="↓"
2067
- accessibilityLabel="Scroll down"
2068
- pad
2069
- disabled={!canSendInput}
2070
- onPress={() => scroll(0, SCROLL_STEP)}
2071
- />
2072
- </View>
2073
- <Text style={styles.devicePresetDetail}>
2074
- Tap the page to click. Swipe to scroll or drag, depending on the gesture above.
2075
- </Text>
2076
- </View>
2077
- </Modal.Content>
2078
- </Modal>
2079
-
2080
- <Modal
2081
- title="Keyboard keys"
2082
- icon={<Icon name="Keyboard" size={18} color={theme.colors.foreground} />}
2083
- open={keysSheetOpen}
2084
- onOpenChange={setKeysSheetOpen}
2085
- >
2086
- <Modal.Content>
2087
- <View style={styles.sheetContent}>
2088
- <View style={styles.sheetGrid}>
2089
- {SPECIAL_KEYS.map(({ key, label }) => (
2090
- <ControlButton
2091
- key={key}
2092
- styles={styles}
2093
- theme={theme}
2094
- label={label}
2095
- accessibilityLabel={`Send ${key} key`}
2096
- large
2097
- disabled={!canSendInput}
2098
- onPress={() => sendEvent({ kind: "key", key })}
2099
- />
2100
- ))}
2101
- </View>
2102
- <Text style={styles.devicePresetDetail}>
2103
- Keys go to the page element that currently has focus in the shared browser.
2104
- </Text>
2105
- </View>
2106
- </Modal.Content>
2107
- </Modal>
2108
- <Modal
2109
- title="Device emulation"
2110
- icon={<Icon name="Smartphone" size={18} color={theme.colors.foreground} />}
2111
- open={devicePickerOpen}
2112
- onOpenChange={setDevicePickerOpen}
2113
- >
2114
- <Modal.Content>
2115
- <View style={styles.deviceModalContent}>
2116
- {DEVICE_PRESETS.map((preset) => {
2117
- const selected = state?.devicePresetId === preset.id;
2118
- return (
2119
- <Pressable
2120
- key={preset.id}
2121
- accessibilityRole="button"
2122
- accessibilityLabel={`Emulate ${preset.label}`}
2123
- accessibilityState={{ selected }}
2124
- disabled={!canControl || deviceMutation.isPending}
2125
- onPress={() => selectDevicePreset(preset.id)}
2126
- style={({ pressed }) => [
2127
- styles.devicePresetRow,
2128
- selected ? styles.devicePresetRowSelected : null,
2129
- pressed ? styles.buttonPressed : null,
2130
- !canControl ? styles.buttonDisabled : null,
2131
- ]}
2132
- >
2133
- <Icon
2134
- name={preset.isMobile ? "Smartphone" : "Monitor"}
2135
- size={18}
2136
- color={selected ? theme.colors.accent : theme.colors.foregroundMuted}
2137
- />
2138
- <Text style={styles.devicePresetText}>{preset.label}</Text>
2139
- <Text style={styles.devicePresetDetail}>
2140
- {preset.viewport.width} × {preset.viewport.height}
2141
- </Text>
2142
- </Pressable>
2143
- );
2144
- })}
1910
+ ) : null}
2145
1911
  <Text style={styles.stripLabel}>Custom viewport</Text>
1912
+ {visibleError ? (
1913
+ <ErrorNotice styles={styles} theme={theme} message={visibleError} />
1914
+ ) : null}
2146
1915
  <View style={styles.customViewportRow}>
2147
1916
  <Field
2148
1917
  styles={styles}
2149
1918
  theme={theme}
2150
1919
  value={viewportWidth}
2151
1920
  accessibilityLabel="Canonical viewport width"
2152
- editable={canControl && !resizeMutation.isPending}
1921
+ editable={Boolean(viewerToken) && !resizeMutation.isPending}
2153
1922
  keyboardType="number-pad"
2154
1923
  inputMode="numeric"
2155
1924
  maxLength={4}
2156
1925
  selectTextOnFocus
2157
1926
  style={styles.viewportField}
2158
1927
  onChangeText={setViewportWidth}
2159
- onSubmit={applyViewport}
1928
+ onSubmit={() =>
1929
+ requestControlled("resize the browser", () =>
1930
+ controlledActionsRef.current?.applyViewport(),
1931
+ )
1932
+ }
2160
1933
  />
2161
1934
  <Text style={styles.multiply}>×</Text>
2162
1935
  <Field
@@ -2164,21 +1937,29 @@ export function SharedBrowserPanel({
2164
1937
  theme={theme}
2165
1938
  value={viewportHeight}
2166
1939
  accessibilityLabel="Canonical viewport height"
2167
- editable={canControl && !resizeMutation.isPending}
1940
+ editable={Boolean(viewerToken) && !resizeMutation.isPending}
2168
1941
  keyboardType="number-pad"
2169
1942
  inputMode="numeric"
2170
1943
  maxLength={4}
2171
1944
  selectTextOnFocus
2172
1945
  style={styles.viewportField}
2173
1946
  onChangeText={setViewportHeight}
2174
- onSubmit={applyViewport}
1947
+ onSubmit={() =>
1948
+ requestControlled("resize the browser", () =>
1949
+ controlledActionsRef.current?.applyViewport(),
1950
+ )
1951
+ }
2175
1952
  />
2176
1953
  <ControlButton
2177
1954
  styles={styles}
2178
1955
  theme={theme}
2179
1956
  label="Apply"
2180
- disabled={!canControl || resizeMutation.isPending}
2181
- onPress={applyViewport}
1957
+ disabled={!viewerToken || anyMutationPending}
1958
+ onPress={() =>
1959
+ requestControlled("resize the browser", () =>
1960
+ controlledActionsRef.current?.applyViewport(),
1961
+ )
1962
+ }
2182
1963
  />
2183
1964
  </View>
2184
1965
  <Text style={styles.devicePresetDetail}>
@@ -2187,6 +1968,298 @@ export function SharedBrowserPanel({
2187
1968
  </View>
2188
1969
  </Modal.Content>
2189
1970
  </Modal>
1971
+ <Modal
1972
+ title="Close browser tab?"
1973
+ icon={<Icon name="X" size={18} color={theme.colors.statusDanger} />}
1974
+ open={tabCloseConfirmationOpen}
1975
+ onOpenChange={(open) => {
1976
+ if (!tabMutationPending) setTabCloseConfirmationOpen(open);
1977
+ }}
1978
+ >
1979
+ <Modal.Content>
1980
+ <BrowserDialogBody
1981
+ styles={styles}
1982
+ actions={
1983
+ <>
1984
+ <ControlButton
1985
+ styles={styles}
1986
+ theme={theme}
1987
+ label="Cancel"
1988
+ disabled={tabMutationPending}
1989
+ onPress={() => setTabCloseConfirmationOpen(false)}
1990
+ />
1991
+ <ControlButton
1992
+ styles={styles}
1993
+ theme={theme}
1994
+ label={tabMutationPending ? "Closing…" : "Close tab"}
1995
+ danger
1996
+ disabled={!viewerToken || tabMutationPending}
1997
+ onPress={() =>
1998
+ requestControlled("close this tab", () =>
1999
+ controlledActionsRef.current?.confirmCloseTab(),
2000
+ )
2001
+ }
2002
+ />
2003
+ </>
2004
+ }
2005
+ >
2006
+ This removes the tab for everyone viewing it, including agents. Other tabs stay open.
2007
+ </BrowserDialogBody>
2008
+ </Modal.Content>
2009
+ </Modal>
2010
+ <Modal
2011
+ title="Close shared browser?"
2012
+ icon={<Icon name="X" size={18} color={theme.colors.statusDanger} />}
2013
+ open={closeConfirmationOpen}
2014
+ onOpenChange={(open) => {
2015
+ if (!closePending) setCloseConfirmationOpen(open);
2016
+ }}
2017
+ >
2018
+ <Modal.Content>
2019
+ <BrowserDialogBody
2020
+ styles={styles}
2021
+ actions={
2022
+ <>
2023
+ <ControlButton
2024
+ styles={styles}
2025
+ theme={theme}
2026
+ label="Cancel"
2027
+ disabled={closePending}
2028
+ onPress={() => setCloseConfirmationOpen(false)}
2029
+ />
2030
+ <ControlButton
2031
+ styles={styles}
2032
+ theme={theme}
2033
+ label={closePending ? "Closing…" : "Close browser"}
2034
+ disabled={!viewerToken || closePending}
2035
+ onPress={() =>
2036
+ requestControlled("close the shared browser", () =>
2037
+ controlledActionsRef.current?.confirmCloseBrowser(),
2038
+ )
2039
+ }
2040
+ />
2041
+ </>
2042
+ }
2043
+ >
2044
+ This closes the browser for every viewer and agent in this workspace. The saved site
2045
+ data stays available when someone explicitly reopens it.
2046
+ </BrowserDialogBody>
2047
+ </Modal.Content>
2048
+ </Modal>
2049
+ <BrowserControlDialog
2050
+ styles={styles}
2051
+ theme={theme}
2052
+ state={state}
2053
+ request={controlRequest}
2054
+ pending={controlRequestPending}
2055
+ available={Boolean(viewerToken)}
2056
+ onCancel={() => setControlRequest(null)}
2057
+ onConfirm={() => void confirmControlRequest()}
2058
+ />
2059
+ {toolbarMenu ? (
2060
+ <BrowserToolbarMenu
2061
+ key={toolbarMenu}
2062
+ theme={theme}
2063
+ compact={layout.compact}
2064
+ title={toolbarMenu === "display" ? "Display options" : "Browser menu"}
2065
+ paneRef={paneRef}
2066
+ paneSize={paneSize}
2067
+ anchorRef={toolbarMenu === "display" ? displayAnchorRef : actionsAnchorRef}
2068
+ preferredHeight={
2069
+ toolbarMenu === "display"
2070
+ ? 300 + preferences.favoritePresetIds.length * (layout.compact ? 44 : 36)
2071
+ : composeAvailable
2072
+ ? 220
2073
+ : 132
2074
+ }
2075
+ onClose={() => closeToolbarMenu()}
2076
+ shouldRestoreFocus={() => menuRestoreFocus.current}
2077
+ onSubmenuOpen={() => {
2078
+ if (canSendInput) setKeysSubmenuOpen(true);
2079
+ }}
2080
+ submenu={
2081
+ toolbarMenu === "actions" && keysSubmenuOpen
2082
+ ? {
2083
+ title: "Send keys",
2084
+ anchorRef: keysAnchorRef,
2085
+ preferredHeight: 40 + SPECIAL_KEYS.length * (layout.compact ? 44 : 36),
2086
+ onBack: () => setKeysSubmenuOpen(false),
2087
+ children: SPECIAL_KEYS.map(({ key, label }) => (
2088
+ <BrowserMenuItem
2089
+ key={key}
2090
+ theme={theme}
2091
+ compact={layout.compact}
2092
+ label={label}
2093
+ disabled={!viewerToken || (canControl && !canSendInput)}
2094
+ onPress={() => {
2095
+ closeToolbarMenu();
2096
+ requestControlled(
2097
+ `send ${label}`,
2098
+ () => controlledActionsRef.current?.sendKey(key),
2099
+ true,
2100
+ );
2101
+ }}
2102
+ />
2103
+ )),
2104
+ }
2105
+ : undefined
2106
+ }
2107
+ >
2108
+ {toolbarMenu === "display" ? (
2109
+ <>
2110
+ <BrowserMenuHeading theme={theme}>View size</BrowserMenuHeading>
2111
+ <BrowserMenuItem
2112
+ theme={theme}
2113
+ compact={layout.compact}
2114
+ label="Fit to panel"
2115
+ icon="Minimize"
2116
+ selected={scaleMode === "fit"}
2117
+ onPress={() => {
2118
+ closeToolbarMenu();
2119
+ setScaleMode("fit");
2120
+ }}
2121
+ />
2122
+ <BrowserMenuItem
2123
+ theme={theme}
2124
+ compact={layout.compact}
2125
+ label="Actual size (100%)"
2126
+ icon="Maximize"
2127
+ selected={scaleMode === "actual"}
2128
+ onPress={() => {
2129
+ closeToolbarMenu();
2130
+ setScaleMode("actual");
2131
+ }}
2132
+ />
2133
+ {layout.platform !== "web" && scaleMode === "actual" ? (
2134
+ <Text
2135
+ style={[styles.devicePresetDetail, { paddingHorizontal: 12, paddingVertical: 4 }]}
2136
+ >
2137
+ Release control to pan this view. While controlling, swipes go to the page.
2138
+ </Text>
2139
+ ) : null}
2140
+ <BrowserMenuSeparator theme={theme} />
2141
+ <BrowserMenuItem
2142
+ theme={theme}
2143
+ compact={layout.compact}
2144
+ label="Fill viewport"
2145
+ icon="Maximize"
2146
+ selected={fillViewportEnabled}
2147
+ disabled={!viewerToken || anyMutationPending || !fillViewportResolution}
2148
+ onPress={() => {
2149
+ closeToolbarMenu();
2150
+ requestControlled("fill the browser viewport", () =>
2151
+ controlledActionsRef.current?.fillViewport(),
2152
+ );
2153
+ }}
2154
+ />
2155
+ <Text
2156
+ style={[styles.devicePresetDetail, { paddingHorizontal: 12, paddingVertical: 4 }]}
2157
+ >
2158
+ {fillViewportDetail}
2159
+ </Text>
2160
+ <BrowserMenuSeparator theme={theme} />
2161
+ <BrowserMenuHeading theme={theme}>Favorite resolutions</BrowserMenuHeading>
2162
+ {preferences.favoritePresetIds.length === 0 ? (
2163
+ <Text
2164
+ style={[styles.devicePresetDetail, { paddingHorizontal: 12, paddingVertical: 4 }]}
2165
+ >
2166
+ Star resolutions in the full list to add them here.
2167
+ </Text>
2168
+ ) : (
2169
+ preferences.favoritePresetIds.map((id) => {
2170
+ const preset = DEVICE_PRESETS.find((item) => item.id === id);
2171
+ return preset ? (
2172
+ <BrowserMenuItem
2173
+ key={id}
2174
+ theme={theme}
2175
+ compact={layout.compact}
2176
+ label={preset.label}
2177
+ icon={preset.isMobile ? "Smartphone" : "Monitor"}
2178
+ selected={selectedResolutionPresetId === id}
2179
+ disabled={!viewerToken || anyMutationPending}
2180
+ onPress={() => {
2181
+ closeToolbarMenu();
2182
+ requestControlled("change the browser resolution", () =>
2183
+ controlledActionsRef.current?.selectDevicePreset(id),
2184
+ );
2185
+ }}
2186
+ />
2187
+ ) : null;
2188
+ })
2189
+ )}
2190
+ <BrowserMenuSeparator theme={theme} />
2191
+ <BrowserMenuItem
2192
+ theme={theme}
2193
+ compact={layout.compact}
2194
+ label="All resolutions and quality"
2195
+ icon="Settings2"
2196
+ onPress={() => {
2197
+ closeToolbarMenu(false);
2198
+ setDevicePickerOpen(true);
2199
+ }}
2200
+ />
2201
+ </>
2202
+ ) : (
2203
+ <>
2204
+ {composeAvailable ? (
2205
+ <BrowserMenuItem
2206
+ theme={theme}
2207
+ compact={layout.compact}
2208
+ label="Compose text"
2209
+ icon="Pencil"
2210
+ disabled={!viewerToken || (canControl && !canSendInput)}
2211
+ onPress={() => {
2212
+ closeToolbarMenu();
2213
+ requestControlled(
2214
+ "compose browser text",
2215
+ () => controlledActionsRef.current?.openCompose("compose"),
2216
+ true,
2217
+ );
2218
+ }}
2219
+ />
2220
+ ) : null}
2221
+ <View ref={keysAnchorRef}>
2222
+ <BrowserMenuItem
2223
+ theme={theme}
2224
+ compact={layout.compact}
2225
+ label="Send keys"
2226
+ icon="Keyboard"
2227
+ expanded={keysSubmenuOpen}
2228
+ disabled={!viewerToken || (canControl && !canSendInput)}
2229
+ onPress={() =>
2230
+ requestControlled("send browser keys", () => setKeysSubmenuOpen(true))
2231
+ }
2232
+ />
2233
+ </View>
2234
+ <BrowserMenuItem
2235
+ theme={theme}
2236
+ compact={layout.compact}
2237
+ label="Reconnect viewer"
2238
+ icon="RotateCw"
2239
+ disabled={attachQuery.isFetching}
2240
+ onPress={() => {
2241
+ closeToolbarMenu();
2242
+ reconnect();
2243
+ }}
2244
+ />
2245
+ <BrowserMenuSeparator theme={theme} />
2246
+ <BrowserMenuItem
2247
+ theme={theme}
2248
+ compact={layout.compact}
2249
+ label="Close shared browser"
2250
+ icon="X"
2251
+ disabled={!viewerToken || anyMutationPending}
2252
+ onPress={() => {
2253
+ closeToolbarMenu(false);
2254
+ requestControlled("close the shared browser", () =>
2255
+ controlledActionsRef.current?.closeBrowser(),
2256
+ );
2257
+ }}
2258
+ />
2259
+ </>
2260
+ )}
2261
+ </BrowserToolbarMenu>
2262
+ ) : null}
2190
2263
  </View>
2191
2264
  );
2192
2265
  }