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