@omercnet/paseo-shared-browser 0.3.1-next.72.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.
@@ -0,0 +1,2098 @@
1
+ import { useMutation, useQuery } from "@tanstack/react-query";
2
+ import {
3
+ type PluginClientContext,
4
+ type PluginWorkspacePanelProps,
5
+ useRpc,
6
+ } from "@getpaseo/plugin/client";
7
+ import { Icon, Modal, ScrollView, TextInput } from "@getpaseo/plugin/client/react-native";
8
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
9
+ import {
10
+ ActivityIndicator,
11
+ Image,
12
+ PanResponder,
13
+ Pressable,
14
+ StyleSheet,
15
+ Text,
16
+ View,
17
+ type GestureResponderEvent,
18
+ type LayoutChangeEvent,
19
+ type StyleProp,
20
+ type TextInputProps,
21
+ type TextStyle,
22
+ type ViewStyle,
23
+ } from "react-native";
24
+ import {
25
+ DEVICE_PRESETS,
26
+ MAX_VIEWPORT,
27
+ MIN_VIEWPORT,
28
+ acquireControlRpc,
29
+ applyDevicePresetRpc,
30
+ attachBrowserRpc,
31
+ captureBrowserRpc,
32
+ detachBrowserRpc,
33
+ listOpenBrowserWorkspacesRpc,
34
+ navigateBrowserRpc,
35
+ releaseControlRpc,
36
+ resizeBrowserRpc,
37
+ sendBrowserInputRpc,
38
+ didBrowserRuntimeRestart,
39
+ isBrowserStateCurrent,
40
+ type BrowserFrame,
41
+ type BrowserInputEvent,
42
+ type BrowserState,
43
+ type DevicePresetId,
44
+ } from "../shared/browser";
45
+
46
+ const SPACE = {
47
+ xxs: 2,
48
+ xs: 4,
49
+ sm: 8,
50
+ md: 12,
51
+ lg: 16,
52
+ } as const;
53
+ const RADIUS = { sm: 6, md: 8, lg: 10 } as const;
54
+ const TYPE = { caption: 11, body: 13, title: 14 } as const;
55
+ const DIMENSION = {
56
+ control: 34,
57
+ touch: 44,
58
+ pad: 60,
59
+ icon: 15,
60
+ addressCompact: 120,
61
+ addressRegular: 220,
62
+ canvasCompact: 220,
63
+ canvasRegular: 320,
64
+ helperMax: 420,
65
+ viewportField: 72,
66
+ typeField: 180,
67
+ screenRadius: 20,
68
+ } as const;
69
+ const DRAG_THRESHOLD = 6;
70
+ const SCROLL_STEP = 520;
71
+ const CAPTURE_INTERVAL_READY = 250;
72
+ const CAPTURE_INTERVAL_WAITING = 1_500;
73
+ const MAX_VIEWER_LABEL_LENGTH = 64;
74
+ const PILL_PRESENCE_POLL_MS = 2_000;
75
+ const MAX_URL_LENGTH = 8_192;
76
+ const MAX_TEXT_LENGTH = 4_000;
77
+ const BYTES_PER_KIBIBYTE = 1_024;
78
+ const MAX_SCROLL_DELTA = 4_000;
79
+
80
+ type Theme = PluginWorkspacePanelProps["theme"];
81
+ type InteractionMode = "click" | "double" | "right";
82
+ type SwipeMode = "scroll" | "drag";
83
+ type SpecialKey = Extract<BrowserInputEvent, { kind: "key" }>["key"];
84
+
85
+ interface Size {
86
+ width: number;
87
+ height: number;
88
+ }
89
+
90
+ interface DisplayRect extends Size {
91
+ x: number;
92
+ y: number;
93
+ }
94
+
95
+ interface DisplayPoint extends Size {
96
+ x: number;
97
+ y: number;
98
+ }
99
+
100
+ const SPECIAL_KEYS: readonly { key: SpecialKey; label: string }[] = [
101
+ { key: "Enter", label: "Enter" },
102
+ { key: "Tab", label: "Tab" },
103
+ { key: "Escape", label: "Esc" },
104
+ { key: "Backspace", label: "Backspace" },
105
+ { key: "Delete", label: "Delete" },
106
+ { key: "ArrowUp", label: "↑" },
107
+ { key: "ArrowDown", label: "↓" },
108
+ { key: "ArrowLeft", label: "←" },
109
+ { key: "ArrowRight", label: "→" },
110
+ { key: "Home", label: "Home" },
111
+ { key: "End", label: "End" },
112
+ { key: "PageUp", label: "Page up" },
113
+ { key: "PageDown", label: "Page down" },
114
+ { key: "Space", label: "Space" },
115
+ ];
116
+
117
+ function containedRect(container: Size, image: Size): DisplayRect | null {
118
+ if (container.width <= 0 || container.height <= 0 || image.width <= 0 || image.height <= 0) {
119
+ return null;
120
+ }
121
+ const scale = Math.min(container.width / image.width, container.height / image.height);
122
+ const width = image.width * scale;
123
+ const height = image.height * scale;
124
+ return {
125
+ x: (container.width - width) / 2,
126
+ y: (container.height - height) / 2,
127
+ width,
128
+ height,
129
+ };
130
+ }
131
+
132
+ function errorMessage(error: unknown): string {
133
+ if (error instanceof Error && error.message.trim()) return error.message;
134
+ return "The shared browser request failed.";
135
+ }
136
+ function hasUnknownMutationOutcome(error: unknown): boolean {
137
+ if (!error || typeof error !== "object") return false;
138
+ const record = error as Record<string, unknown>;
139
+ const code = typeof record.code === "string" ? record.code.toLowerCase() : "";
140
+ const name = typeof record.name === "string" ? record.name : "";
141
+ if (code === "unknown_outcome") return true;
142
+ if (
143
+ code === "transport_closed_after_dispatch" ||
144
+ code === "transport_lost_after_dispatch" ||
145
+ code === "rpc_timeout" ||
146
+ name === "RpcTimeoutError" ||
147
+ name === "TransportLostAfterDispatchError"
148
+ ) {
149
+ return true;
150
+ }
151
+ const dispatched = record.dispatched === true || record.requestDispatched === true;
152
+ return dispatched && /(transport|connection|timeout)/.test(code || name.toLowerCase());
153
+ }
154
+
155
+ function isFrameCurrent(frame: BrowserFrame, state: BrowserState): boolean {
156
+ return (
157
+ frame.sessionId === state.sessionId &&
158
+ (!frame.runtimeId || !state.runtimeId || frame.runtimeId === state.runtimeId) &&
159
+ frame.navigationGeneration === state.navigationGeneration &&
160
+ frame.viewportGeneration === state.viewportGeneration
161
+ );
162
+ }
163
+
164
+ function clampScrollDelta(delta: number): number {
165
+ return Math.max(-MAX_SCROLL_DELTA, Math.min(MAX_SCROLL_DELTA, Math.round(delta)));
166
+ }
167
+
168
+ function createStyles(theme: Theme, compact: boolean) {
169
+ return StyleSheet.create({
170
+ screen: {
171
+ flex: 1,
172
+ minHeight: 0,
173
+ backgroundColor: theme.colors.surface0,
174
+ },
175
+ statusRow: {
176
+ minHeight: 30,
177
+ paddingHorizontal: SPACE.sm,
178
+ paddingVertical: SPACE.xxs,
179
+ flexDirection: "row",
180
+ alignItems: "center",
181
+ justifyContent: "space-between",
182
+ gap: SPACE.sm,
183
+ borderBottomWidth: 1,
184
+ borderBottomColor: theme.colors.border,
185
+ backgroundColor: theme.colors.surface0,
186
+ },
187
+ statusSummary: {
188
+ minWidth: 0,
189
+ flex: 1,
190
+ flexDirection: "row",
191
+ alignItems: "center",
192
+ flexWrap: "wrap",
193
+ gap: SPACE.sm,
194
+ },
195
+ statusDot: {
196
+ width: SPACE.sm,
197
+ height: SPACE.sm,
198
+ borderRadius: RADIUS.sm,
199
+ },
200
+ statusText: {
201
+ color: theme.colors.foreground,
202
+ fontSize: TYPE.body,
203
+ fontWeight: "600",
204
+ },
205
+ mutedText: {
206
+ color: theme.colors.foregroundMuted,
207
+ fontSize: TYPE.caption,
208
+ },
209
+ controllerText: {
210
+ color: theme.colors.foregroundMuted,
211
+ fontSize: TYPE.caption,
212
+ flexShrink: 1,
213
+ },
214
+ actionRow: {
215
+ flexDirection: "row",
216
+ alignItems: "center",
217
+ gap: SPACE.xs,
218
+ },
219
+ chrome: {
220
+ minHeight: 40,
221
+ paddingHorizontal: SPACE.sm,
222
+ paddingVertical: SPACE.xs,
223
+ borderBottomWidth: 1,
224
+ borderBottomColor: theme.colors.border,
225
+ backgroundColor: theme.colors.surface0,
226
+ },
227
+ addressRow: {
228
+ flexDirection: "row",
229
+ alignItems: "center",
230
+ gap: SPACE.xs,
231
+ },
232
+ addressInput: {
233
+ flex: 1,
234
+ minWidth: compact ? DIMENSION.addressCompact : DIMENSION.addressRegular,
235
+ },
236
+ chromeAddressInput: {
237
+ height: 28,
238
+ borderRadius: RADIUS.md,
239
+ backgroundColor: theme.colors.surface1,
240
+ },
241
+ chromeIconButton: {
242
+ width: 28,
243
+ height: 28,
244
+ borderRadius: RADIUS.md,
245
+ alignItems: "center",
246
+ justifyContent: "center",
247
+ },
248
+ chromeIconButtonHovered: {
249
+ backgroundColor: theme.colors.surface2,
250
+ },
251
+ chromeIconButtonPressed: {
252
+ opacity: 0.72,
253
+ },
254
+ chromeIconButtonDisabled: {
255
+ opacity: 0.45,
256
+ },
257
+ toolbarContent: {
258
+ flexDirection: "row",
259
+ alignItems: "center",
260
+ gap: SPACE.xs,
261
+ },
262
+ button: {
263
+ minHeight: DIMENSION.control,
264
+ minWidth: DIMENSION.control,
265
+ paddingHorizontal: SPACE.sm,
266
+ borderWidth: 1,
267
+ borderColor: theme.colors.border,
268
+ borderRadius: RADIUS.sm,
269
+ backgroundColor: theme.colors.surface2,
270
+ flexDirection: "row",
271
+ alignItems: "center",
272
+ justifyContent: "center",
273
+ gap: SPACE.xs,
274
+ },
275
+ buttonSelected: {
276
+ borderColor: theme.colors.accent,
277
+ backgroundColor: theme.colors.accent,
278
+ },
279
+ buttonPrimary: {
280
+ borderColor: theme.colors.accent,
281
+ backgroundColor: theme.colors.accent,
282
+ },
283
+ buttonDanger: {
284
+ borderColor: theme.colors.statusWarning,
285
+ },
286
+ buttonHovered: {
287
+ borderColor: theme.colors.foregroundMuted,
288
+ },
289
+ buttonPressed: {
290
+ opacity: 0.72,
291
+ },
292
+ buttonFocused: {
293
+ borderColor: theme.colors.accent,
294
+ borderWidth: 2,
295
+ },
296
+ buttonDisabled: {
297
+ opacity: 0.42,
298
+ },
299
+ buttonText: {
300
+ color: theme.colors.foreground,
301
+ fontSize: TYPE.caption,
302
+ fontWeight: "600",
303
+ },
304
+ buttonTextSelected: {
305
+ color: theme.colors.accentForeground,
306
+ },
307
+ field: {
308
+ height: DIMENSION.control,
309
+ paddingHorizontal: SPACE.sm,
310
+ borderWidth: 1,
311
+ borderColor: theme.colors.border,
312
+ borderRadius: RADIUS.sm,
313
+ color: theme.colors.foreground,
314
+ backgroundColor: theme.colors.surface2,
315
+ fontSize: TYPE.body,
316
+ },
317
+ fieldFocused: {
318
+ borderColor: theme.colors.accent,
319
+ borderWidth: 2,
320
+ },
321
+ fieldDisabled: {
322
+ opacity: 0.5,
323
+ },
324
+ errorRow: {
325
+ paddingHorizontal: SPACE.sm,
326
+ paddingVertical: SPACE.xs,
327
+ borderLeftWidth: SPACE.xs,
328
+ borderLeftColor: theme.colors.statusDanger,
329
+ backgroundColor: theme.colors.surface1,
330
+ flexDirection: "row",
331
+ alignItems: "center",
332
+ gap: SPACE.sm,
333
+ },
334
+ errorText: {
335
+ flex: 1,
336
+ color: theme.colors.statusDanger,
337
+ fontSize: TYPE.caption,
338
+ },
339
+ canvasShell: {
340
+ flex: 1,
341
+ minHeight: compact ? DIMENSION.canvasCompact : DIMENSION.canvasRegular,
342
+ minWidth: 0,
343
+ margin: compact ? SPACE.sm : 0,
344
+ borderRadius: compact ? DIMENSION.screenRadius : 0,
345
+ overflow: "hidden",
346
+ backgroundColor: theme.colors.surface1,
347
+ },
348
+ canvas: {
349
+ flex: 1,
350
+ minHeight: 0,
351
+ overflow: "hidden",
352
+ },
353
+ frame: {
354
+ position: "absolute",
355
+ borderRadius: compact ? DIMENSION.screenRadius - SPACE.xs : 0,
356
+ },
357
+ interactionLayer: {
358
+ position: "absolute",
359
+ },
360
+ canvasState: {
361
+ flex: 1,
362
+ alignItems: "center",
363
+ justifyContent: "center",
364
+ padding: SPACE.lg,
365
+ gap: SPACE.sm,
366
+ },
367
+ canvasTitle: {
368
+ color: theme.colors.foreground,
369
+ fontSize: TYPE.title,
370
+ fontWeight: "600",
371
+ textAlign: "center",
372
+ },
373
+ canvasDetail: {
374
+ color: theme.colors.foregroundMuted,
375
+ fontSize: TYPE.caption,
376
+ textAlign: "center",
377
+ maxWidth: DIMENSION.helperMax,
378
+ },
379
+ canvasFooter: {
380
+ minHeight: DIMENSION.control,
381
+ paddingHorizontal: SPACE.sm,
382
+ borderTopWidth: 1,
383
+ borderTopColor: theme.colors.border,
384
+ flexDirection: "row",
385
+ alignItems: "center",
386
+ justifyContent: "space-between",
387
+ gap: SPACE.sm,
388
+ backgroundColor: theme.colors.surface1,
389
+ },
390
+ canvasFooterText: {
391
+ color: theme.colors.foregroundMuted,
392
+ fontSize: TYPE.caption,
393
+ flexShrink: 1,
394
+ },
395
+ controls: {
396
+ paddingHorizontal: compact ? SPACE.md : SPACE.sm,
397
+ paddingTop: compact ? SPACE.md : SPACE.sm,
398
+ paddingBottom: compact ? SPACE.lg : SPACE.sm,
399
+ gap: compact ? SPACE.sm : SPACE.xs,
400
+ borderTopWidth: 1,
401
+ borderTopColor: theme.colors.border,
402
+ backgroundColor: theme.colors.surface0,
403
+ },
404
+ mobileRow: {
405
+ flexDirection: "row",
406
+ alignItems: "center",
407
+ gap: SPACE.sm,
408
+ },
409
+ sheetContent: {
410
+ gap: SPACE.md,
411
+ padding: SPACE.sm,
412
+ },
413
+ sheetGrid: {
414
+ flexDirection: "row",
415
+ flexWrap: "wrap",
416
+ gap: SPACE.sm,
417
+ },
418
+ sheetPad: {
419
+ alignItems: "center",
420
+ gap: SPACE.sm,
421
+ },
422
+ sheetPadRow: {
423
+ flexDirection: "row",
424
+ alignItems: "center",
425
+ justifyContent: "center",
426
+ gap: SPACE.sm,
427
+ },
428
+ padSpacer: {
429
+ width: DIMENSION.pad,
430
+ },
431
+ controlStrip: {
432
+ minHeight: DIMENSION.control,
433
+ flexDirection: "row",
434
+ alignItems: "center",
435
+ gap: SPACE.xs,
436
+ },
437
+ stripLabel: {
438
+ color: theme.colors.foregroundMuted,
439
+ fontSize: TYPE.caption,
440
+ fontWeight: "600",
441
+ marginRight: SPACE.xs,
442
+ },
443
+ separator: {
444
+ width: 1,
445
+ alignSelf: "stretch",
446
+ marginVertical: SPACE.xs,
447
+ marginHorizontal: SPACE.xs,
448
+ backgroundColor: theme.colors.border,
449
+ },
450
+ viewportField: {
451
+ width: DIMENSION.viewportField,
452
+ textAlign: "center",
453
+ },
454
+ multiply: {
455
+ color: theme.colors.foregroundMuted,
456
+ fontSize: TYPE.body,
457
+ },
458
+ typeRow: {
459
+ flexDirection: compact ? "column" : "row",
460
+ gap: SPACE.xs,
461
+ },
462
+ typeInput: {
463
+ flex: 1,
464
+ minWidth: compact ? undefined : DIMENSION.typeField,
465
+ },
466
+ keysContent: {
467
+ flexDirection: "row",
468
+ alignItems: "center",
469
+ gap: SPACE.xs,
470
+ paddingTop: SPACE.xs,
471
+ },
472
+ deviceModalContent: {
473
+ gap: SPACE.sm,
474
+ padding: SPACE.sm,
475
+ },
476
+ devicePresetRow: {
477
+ minHeight: 46,
478
+ paddingHorizontal: SPACE.md,
479
+ paddingVertical: SPACE.sm,
480
+ borderWidth: 1,
481
+ borderColor: theme.colors.border,
482
+ borderRadius: RADIUS.md,
483
+ backgroundColor: theme.colors.surface1,
484
+ flexDirection: "row",
485
+ alignItems: "center",
486
+ gap: SPACE.sm,
487
+ },
488
+ devicePresetRowSelected: {
489
+ borderColor: theme.colors.accent,
490
+ backgroundColor: theme.colors.surface2,
491
+ },
492
+ devicePresetText: {
493
+ flex: 1,
494
+ color: theme.colors.foreground,
495
+ fontSize: TYPE.body,
496
+ fontWeight: "600",
497
+ },
498
+ devicePresetDetail: {
499
+ color: theme.colors.foregroundMuted,
500
+ fontSize: TYPE.caption,
501
+ },
502
+ customViewportRow: {
503
+ flexDirection: "row",
504
+ alignItems: "center",
505
+ gap: SPACE.xs,
506
+ },
507
+ buttonLarge: {
508
+ minHeight: DIMENSION.touch,
509
+ paddingHorizontal: SPACE.md,
510
+ borderRadius: RADIUS.md,
511
+ },
512
+ buttonFill: {
513
+ flex: 1,
514
+ },
515
+ buttonPad: {
516
+ width: DIMENSION.pad,
517
+ minHeight: DIMENSION.touch,
518
+ },
519
+ });
520
+ }
521
+
522
+ interface ControlButtonStyles {
523
+ button: ViewStyle;
524
+ buttonSelected: ViewStyle;
525
+ buttonPrimary: ViewStyle;
526
+ buttonDanger: ViewStyle;
527
+ buttonHovered: ViewStyle;
528
+ buttonPressed: ViewStyle;
529
+ buttonFocused: ViewStyle;
530
+ buttonDisabled: ViewStyle;
531
+ buttonLarge: ViewStyle;
532
+ buttonFill: ViewStyle;
533
+ buttonPad: ViewStyle;
534
+ buttonText: TextStyle;
535
+ buttonTextSelected: TextStyle;
536
+ }
537
+
538
+ interface FieldStyles {
539
+ field: TextStyle;
540
+ fieldFocused: TextStyle;
541
+ fieldDisabled: TextStyle;
542
+ }
543
+
544
+ interface ErrorNoticeStyles extends ControlButtonStyles {
545
+ errorRow: ViewStyle;
546
+ errorText: TextStyle;
547
+ }
548
+
549
+ interface CanvasPlaceholderStyles {
550
+ canvasState: ViewStyle;
551
+ canvasTitle: TextStyle;
552
+ canvasDetail: TextStyle;
553
+ }
554
+
555
+ interface ControlButtonProps {
556
+ styles: ControlButtonStyles;
557
+ theme: Theme;
558
+ label: string;
559
+ accessibilityLabel?: string;
560
+ icon?: string;
561
+ selected?: boolean;
562
+ primary?: boolean;
563
+ danger?: boolean;
564
+ large?: boolean;
565
+ fill?: boolean;
566
+ pad?: boolean;
567
+ disabled?: boolean;
568
+ onPress(): void;
569
+ }
570
+
571
+ function ControlButton({
572
+ styles,
573
+ theme,
574
+ label,
575
+ accessibilityLabel,
576
+ icon,
577
+ selected = false,
578
+ primary = false,
579
+ danger = false,
580
+ large = false,
581
+ fill = false,
582
+ pad = false,
583
+ disabled = false,
584
+ onPress,
585
+ }: ControlButtonProps) {
586
+ const [focused, setFocused] = useState(false);
587
+ const [hovered, setHovered] = useState(false);
588
+ const highlighted = selected || primary;
589
+ return (
590
+ <Pressable
591
+ accessibilityRole="button"
592
+ accessibilityLabel={accessibilityLabel ?? label}
593
+ accessibilityState={{ disabled, selected }}
594
+ disabled={disabled}
595
+ onFocus={() => setFocused(true)}
596
+ onBlur={() => setFocused(false)}
597
+ onHoverIn={() => setHovered(true)}
598
+ onHoverOut={() => setHovered(false)}
599
+ onPress={onPress}
600
+ style={({ pressed }) => [
601
+ styles.button,
602
+ selected ? styles.buttonSelected : null,
603
+ primary ? styles.buttonPrimary : null,
604
+ danger ? styles.buttonDanger : null,
605
+ hovered && !highlighted ? styles.buttonHovered : null,
606
+ pressed ? styles.buttonPressed : null,
607
+ focused ? styles.buttonFocused : null,
608
+ disabled ? styles.buttonDisabled : null,
609
+ large || pad ? styles.buttonLarge : null,
610
+ fill ? styles.buttonFill : null,
611
+ pad ? styles.buttonPad : null,
612
+ ]}
613
+ >
614
+ {icon ? (
615
+ <Icon
616
+ name={icon}
617
+ size={DIMENSION.icon}
618
+ color={highlighted ? theme.colors.accentForeground : theme.colors.foregroundMuted}
619
+ />
620
+ ) : null}
621
+ <Text style={[styles.buttonText, highlighted ? styles.buttonTextSelected : null]}>
622
+ {label}
623
+ </Text>
624
+ </Pressable>
625
+ );
626
+ }
627
+
628
+ interface ChromeIconButtonStyles {
629
+ chromeIconButton: ViewStyle;
630
+ chromeIconButtonHovered: ViewStyle;
631
+ chromeIconButtonPressed: ViewStyle;
632
+ chromeIconButtonDisabled: ViewStyle;
633
+ }
634
+
635
+ function ChromeIconButton({
636
+ styles,
637
+ theme,
638
+ label,
639
+ icon,
640
+ selected = false,
641
+ disabled = false,
642
+ onPress,
643
+ }: {
644
+ styles: ChromeIconButtonStyles;
645
+ theme: Theme;
646
+ label: string;
647
+ icon: string;
648
+ selected?: boolean;
649
+ disabled?: boolean;
650
+ onPress(): void;
651
+ }) {
652
+ const [hovered, setHovered] = useState(false);
653
+ return (
654
+ <Pressable
655
+ accessibilityRole="button"
656
+ accessibilityLabel={label}
657
+ accessibilityState={{ disabled, selected }}
658
+ disabled={disabled}
659
+ onHoverIn={() => setHovered(true)}
660
+ onHoverOut={() => setHovered(false)}
661
+ onPress={onPress}
662
+ style={({ pressed }) => [
663
+ styles.chromeIconButton,
664
+ hovered ? styles.chromeIconButtonHovered : null,
665
+ pressed ? styles.chromeIconButtonPressed : null,
666
+ disabled ? styles.chromeIconButtonDisabled : null,
667
+ ]}
668
+ >
669
+ <Icon
670
+ name={icon}
671
+ size={16}
672
+ color={selected ? theme.colors.accent : theme.colors.foregroundMuted}
673
+ />
674
+ </Pressable>
675
+ );
676
+ }
677
+
678
+ interface FieldProps {
679
+ styles: FieldStyles;
680
+ theme: Theme;
681
+ value: string;
682
+ accessibilityLabel: string;
683
+ placeholder?: string;
684
+ editable?: boolean;
685
+ maxLength?: number;
686
+ keyboardType?: TextInputProps["keyboardType"];
687
+ inputMode?: TextInputProps["inputMode"];
688
+ returnKeyType?: TextInputProps["returnKeyType"];
689
+ selectTextOnFocus?: boolean;
690
+ style?: StyleProp<TextStyle>;
691
+ onChangeText(value: string): void;
692
+ onSubmit?(): void;
693
+ onFocus?(): void;
694
+ onBlur?(): void;
695
+ }
696
+
697
+ function Field({
698
+ styles,
699
+ theme,
700
+ value,
701
+ accessibilityLabel,
702
+ placeholder,
703
+ editable = true,
704
+ maxLength,
705
+ keyboardType,
706
+ inputMode,
707
+ returnKeyType,
708
+ selectTextOnFocus,
709
+ style,
710
+ onChangeText,
711
+ onSubmit,
712
+ onFocus,
713
+ onBlur,
714
+ }: FieldProps) {
715
+ const [focused, setFocused] = useState(false);
716
+ return (
717
+ <TextInput
718
+ accessibilityLabel={accessibilityLabel}
719
+ autoCapitalize="none"
720
+ autoCorrect={false}
721
+ spellCheck={false}
722
+ editable={editable}
723
+ keyboardType={keyboardType}
724
+ inputMode={inputMode}
725
+ maxLength={maxLength}
726
+ onBlur={() => {
727
+ setFocused(false);
728
+ onBlur?.();
729
+ }}
730
+ onChangeText={onChangeText}
731
+ onFocus={() => {
732
+ setFocused(true);
733
+ onFocus?.();
734
+ }}
735
+ onSubmitEditing={onSubmit}
736
+ placeholder={placeholder}
737
+ placeholderTextColor={theme.colors.foregroundMuted}
738
+ returnKeyType={returnKeyType}
739
+ selectionColor={theme.colors.accent}
740
+ selectTextOnFocus={selectTextOnFocus}
741
+ style={[
742
+ styles.field,
743
+ style,
744
+ focused ? styles.fieldFocused : null,
745
+ !editable ? styles.fieldDisabled : null,
746
+ ]}
747
+ value={value}
748
+ />
749
+ );
750
+ }
751
+
752
+ function ErrorNotice({
753
+ styles,
754
+ theme,
755
+ message,
756
+ action,
757
+ onAction,
758
+ actionDisabled = false,
759
+ }: {
760
+ styles: ErrorNoticeStyles;
761
+ theme: Theme;
762
+ message: string;
763
+ action?: string | undefined;
764
+ onAction?: (() => void) | undefined;
765
+ actionDisabled?: boolean;
766
+ }) {
767
+ return (
768
+ <View accessibilityRole="alert" style={styles.errorRow}>
769
+ <Icon name="CircleAlert" size={DIMENSION.icon} color={theme.colors.statusDanger} />
770
+ <Text style={styles.errorText}>{message}</Text>
771
+ {action && onAction ? (
772
+ <ControlButton
773
+ styles={styles}
774
+ theme={theme}
775
+ label={action}
776
+ disabled={actionDisabled}
777
+ onPress={onAction}
778
+ />
779
+ ) : null}
780
+ </View>
781
+ );
782
+ }
783
+
784
+ function CanvasPlaceholder({
785
+ styles,
786
+ theme,
787
+ title,
788
+ detail,
789
+ loading = false,
790
+ }: {
791
+ styles: CanvasPlaceholderStyles;
792
+ theme: Theme;
793
+ title: string;
794
+ detail: string;
795
+ loading?: boolean;
796
+ }) {
797
+ return (
798
+ <View style={styles.canvasState}>
799
+ {loading ? <ActivityIndicator color={theme.colors.accent} /> : null}
800
+ <Text style={styles.canvasTitle}>{title}</Text>
801
+ <Text style={styles.canvasDetail}>{detail}</Text>
802
+ </View>
803
+ );
804
+ }
805
+
806
+ export function contributeSharedBrowserClient(client: PluginClientContext) {
807
+ const agents = new Map<string, { id: string; workspaceId: string }>();
808
+ const pills = new Map<string, { workspaceId: string; remove: () => void }>();
809
+ let openWorkspaceIds = new Set<string>();
810
+ let refreshing = false;
811
+ let stopped = false;
812
+
813
+ const removePill = (agentId: string) => {
814
+ pills.get(agentId)?.remove();
815
+ pills.delete(agentId);
816
+ };
817
+ const syncPill = (agent: { id: string; workspaceId: string }) => {
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
+
862
+ const unsubscribe = client.paseo.agents.subscribe((update) => {
863
+ if (update.kind === "remove") {
864
+ agents.delete(update.agentId);
865
+ removePill(update.agentId);
866
+ return;
867
+ }
868
+ const { id, workspaceId } = update.agent;
869
+ if (!workspaceId) {
870
+ agents.delete(id);
871
+ removePill(id);
872
+ return;
873
+ }
874
+ const agent = { id, workspaceId };
875
+ agents.set(id, agent);
876
+ syncPill(agent);
877
+ void refreshPresence();
878
+ });
879
+ void client.paseo.agents
880
+ .list()
881
+ .then(async ({ entries }) => {
882
+ for (const { agent } of entries) {
883
+ if (agent.workspaceId)
884
+ agents.set(agent.id, { id: agent.id, workspaceId: agent.workspaceId });
885
+ }
886
+ await refreshPresence();
887
+ })
888
+ .catch(() => undefined);
889
+ const presenceTimer = setInterval(() => void refreshPresence(), PILL_PRESENCE_POLL_MS);
890
+
891
+ return () => {
892
+ if (stopped) return;
893
+ stopped = true;
894
+ clearInterval(presenceTimer);
895
+ unsubscribe();
896
+ for (const { remove } of pills.values()) remove();
897
+ pills.clear();
898
+ agents.clear();
899
+ };
900
+ }
901
+ export function SharedBrowserPanel({
902
+ theme,
903
+ host,
904
+ layout,
905
+ workspaceId,
906
+ }: PluginWorkspacePanelProps) {
907
+ const styles = useMemo(() => createStyles(theme, layout.compact), [theme, layout.compact]);
908
+ const viewerLabel = useState(() =>
909
+ `Paseo ${layout.platform} · ${host.label} · ${Date.now().toString(36)}${Math.random()
910
+ .toString(36)
911
+ .slice(2, 6)}`.slice(0, MAX_VIEWER_LABEL_LENGTH),
912
+ )[0];
913
+
914
+ const attachBrowser = useRpc(attachBrowserRpc);
915
+ const detachBrowser = useRpc(detachBrowserRpc);
916
+ const captureBrowser = useRpc(captureBrowserRpc);
917
+ const acquireControl = useRpc(acquireControlRpc);
918
+ const releaseControl = useRpc(releaseControlRpc);
919
+ const navigateBrowser = useRpc(navigateBrowserRpc);
920
+ const resizeBrowser = useRpc(resizeBrowserRpc);
921
+ const applyDevicePreset = useRpc(applyDevicePresetRpc);
922
+ const sendBrowserInput = useRpc(sendBrowserInputRpc);
923
+
924
+ const mountedRef = useRef(false);
925
+ const activeViewerTokenRef = useRef<string | null>(null);
926
+ const stateRef = useRef<BrowserState | null>(null);
927
+ const frameRef = useRef<BrowserFrame | null>(null);
928
+ const lastPointRef = useRef<{ x: number; y: number } | null>(null);
929
+ const dragStartRef = useRef<{ x: number; y: number } | null>(null);
930
+ const mutationEpochRef = useRef(0);
931
+ const captureInFlightRef = useRef(false);
932
+
933
+ const [state, setState] = useState<BrowserState | null>(null);
934
+ const [frame, setFrame] = useState<BrowserFrame | null>(null);
935
+ const [controlToken, setControlToken] = useState<string | null>(null);
936
+ const [operationError, setOperationError] = useState<string | null>(null);
937
+ const [imageError, setImageError] = useState(false);
938
+ const [reconnecting, setReconnecting] = useState(false);
939
+ const [runtimeNotice, setRuntimeNotice] = useState<string | null>(null);
940
+ const [addressDraft, setAddressDraft] = useState("");
941
+ const [addressFocused, setAddressFocused] = useState(false);
942
+ const [viewportWidth, setViewportWidth] = useState("");
943
+ const [viewportHeight, setViewportHeight] = useState("");
944
+ const [typeDraft, setTypeDraft] = useState("");
945
+ const [interactionMode, setInteractionMode] = useState<InteractionMode>("click");
946
+ const [devicePickerOpen, setDevicePickerOpen] = useState(false);
947
+ const [pointerSheetOpen, setPointerSheetOpen] = useState(false);
948
+ const [keysSheetOpen, setKeysSheetOpen] = useState(false);
949
+ const [swipeMode, setSwipeMode] = useState<SwipeMode>(layout.compact ? "scroll" : "drag");
950
+ const [containerSize, setContainerSize] = useState<Size>({ width: 0, height: 0 });
951
+
952
+ useEffect(() => {
953
+ mountedRef.current = true;
954
+ return () => {
955
+ mountedRef.current = false;
956
+ };
957
+ }, []);
958
+
959
+ const acceptState = useCallback((next: BrowserState) => {
960
+ const previous = stateRef.current;
961
+ if (previous && !isBrowserStateCurrent(previous, next)) return false;
962
+ const currentFrame = frameRef.current;
963
+ if (previous && didBrowserRuntimeRestart(previous, next)) {
964
+ setRuntimeNotice(
965
+ "Browser restarted. The preserved viewer connection now targets the new runtime.",
966
+ );
967
+ setControlToken(null);
968
+ }
969
+ if (currentFrame && !isFrameCurrent(currentFrame, next)) {
970
+ frameRef.current = null;
971
+ setFrame(null);
972
+ lastPointRef.current = null;
973
+ }
974
+ stateRef.current = next;
975
+ setState(next);
976
+ return true;
977
+ }, []);
978
+
979
+ const attachQuery = useQuery({
980
+ queryKey: ["shared-browser", "attach", workspaceId, viewerLabel],
981
+ queryFn: async () => {
982
+ const result = await attachBrowser({ workspaceId, viewerLabel });
983
+ if (!mountedRef.current) {
984
+ await detachBrowser({ viewerToken: result.viewerToken }).catch(() => undefined);
985
+ throw new Error("The browser panel closed before attachment completed.");
986
+ }
987
+ return result;
988
+ },
989
+ retry: false,
990
+ staleTime: Number.POSITIVE_INFINITY,
991
+ gcTime: 0,
992
+ refetchOnWindowFocus: false,
993
+ });
994
+
995
+ const viewerToken = reconnecting ? null : (attachQuery.data?.viewerToken ?? null);
996
+
997
+ useEffect(() => {
998
+ if (!viewerToken) return;
999
+ activeViewerTokenRef.current = viewerToken;
1000
+ return () => {
1001
+ if (activeViewerTokenRef.current === viewerToken) activeViewerTokenRef.current = null;
1002
+ void detachBrowser({ viewerToken }).catch(() => undefined);
1003
+ };
1004
+ }, [detachBrowser, viewerToken]);
1005
+
1006
+ useEffect(() => {
1007
+ if (!reconnecting && attachQuery.data) acceptState(attachQuery.data.state);
1008
+ }, [acceptState, attachQuery.data, reconnecting]);
1009
+
1010
+ useEffect(() => {
1011
+ stateRef.current = null;
1012
+ frameRef.current = null;
1013
+ setState(null);
1014
+ setFrame(null);
1015
+ setControlToken(null);
1016
+ setOperationError(null);
1017
+ setRuntimeNotice(null);
1018
+ }, [workspaceId]);
1019
+
1020
+ const captureQuery = useQuery({
1021
+ queryKey: ["shared-browser", "capture", viewerToken],
1022
+ queryFn: async () => {
1023
+ if (!viewerToken) throw new Error("The browser viewer is not attached.");
1024
+ const mutationEpoch = mutationEpochRef.current;
1025
+ const knownFrame = frameRef.current;
1026
+ captureInFlightRef.current = true;
1027
+ try {
1028
+ const result = await captureBrowser({
1029
+ viewerToken,
1030
+ quality: "medium",
1031
+ knownFrameId: knownFrame?.frameId ?? null,
1032
+ });
1033
+ return { ...result, mutationEpoch };
1034
+ } finally {
1035
+ captureInFlightRef.current = false;
1036
+ }
1037
+ },
1038
+ enabled: Boolean(viewerToken),
1039
+ retry: false,
1040
+ refetchInterval: (query) =>
1041
+ query.state.data?.state.status === "ready"
1042
+ ? CAPTURE_INTERVAL_READY
1043
+ : CAPTURE_INTERVAL_WAITING,
1044
+ refetchIntervalInBackground: false,
1045
+ refetchOnWindowFocus: true,
1046
+ staleTime: 0,
1047
+ });
1048
+
1049
+ useEffect(() => {
1050
+ const result = captureQuery.data;
1051
+ if (
1052
+ !result ||
1053
+ result.mutationEpoch !== mutationEpochRef.current ||
1054
+ !acceptState(result.state)
1055
+ ) {
1056
+ return;
1057
+ }
1058
+ if (result.frame && isFrameCurrent(result.frame, result.state)) {
1059
+ frameRef.current = result.frame;
1060
+ setImageError(false);
1061
+ setFrame(result.frame);
1062
+ }
1063
+ }, [acceptState, captureQuery.data]);
1064
+
1065
+ useEffect(() => {
1066
+ if (state?.controller !== "self" && controlToken) setControlToken(null);
1067
+ }, [controlToken, state?.controller, state?.sessionId]);
1068
+
1069
+ useEffect(() => {
1070
+ if (!state || addressFocused) return;
1071
+ setAddressDraft(state.url);
1072
+ }, [addressFocused, state?.sessionId, state?.url]);
1073
+
1074
+ useEffect(() => {
1075
+ if (!state) return;
1076
+ setViewportWidth(String(state.viewport.width));
1077
+ setViewportHeight(String(state.viewport.height));
1078
+ }, [state?.sessionId, state?.viewport.height, state?.viewport.width]);
1079
+
1080
+ useEffect(() => {
1081
+ setImageError(false);
1082
+ }, [frame?.frameId]);
1083
+
1084
+ const refreshCapture = useCallback(() => {
1085
+ const requestWasInFlight = captureInFlightRef.current;
1086
+ const request = captureQuery.refetch({ cancelRefetch: false });
1087
+ if (requestWasInFlight) {
1088
+ void request.then(() => captureQuery.refetch({ cancelRefetch: false }));
1089
+ }
1090
+ }, [captureQuery.refetch]);
1091
+
1092
+ const mutationFailed = useCallback(
1093
+ (error: unknown) => {
1094
+ const message = errorMessage(error);
1095
+ setOperationError(
1096
+ hasUnknownMutationOutcome(error)
1097
+ ? `${message} Mutation outcome is unknown; state refreshed and the action was not replayed.`
1098
+ : message,
1099
+ );
1100
+ refreshCapture();
1101
+ },
1102
+ [refreshCapture],
1103
+ );
1104
+
1105
+ const mutationSucceeded = useCallback(
1106
+ (next: BrowserState) => {
1107
+ setOperationError(null);
1108
+ acceptState(next);
1109
+ refreshCapture();
1110
+ },
1111
+ [acceptState, refreshCapture],
1112
+ );
1113
+
1114
+ const acquireMutation = useMutation({
1115
+ mutationFn: acquireControl,
1116
+ retry: false,
1117
+ onSuccess: (result) => {
1118
+ setControlToken(result.controlToken);
1119
+ mutationSucceeded(result.state);
1120
+ },
1121
+ onError: mutationFailed,
1122
+ });
1123
+ const releaseMutation = useMutation({
1124
+ mutationFn: releaseControl,
1125
+ retry: false,
1126
+ onSuccess: (result) => {
1127
+ setControlToken(null);
1128
+ mutationSucceeded(result.state);
1129
+ },
1130
+ onError: mutationFailed,
1131
+ });
1132
+ const navigateMutation = useMutation({
1133
+ mutationFn: navigateBrowser,
1134
+ retry: false,
1135
+ onSuccess: (result) => mutationSucceeded(result.state),
1136
+ onError: mutationFailed,
1137
+ });
1138
+ const resizeMutation = useMutation({
1139
+ mutationFn: resizeBrowser,
1140
+ retry: false,
1141
+ onSuccess: (result) => mutationSucceeded(result.state),
1142
+ onError: mutationFailed,
1143
+ });
1144
+ const deviceMutation = useMutation({
1145
+ mutationFn: applyDevicePreset,
1146
+ retry: false,
1147
+ onSuccess: (result) => mutationSucceeded(result.state),
1148
+ onError: mutationFailed,
1149
+ });
1150
+ const inputMutation = useMutation({
1151
+ mutationFn: sendBrowserInput,
1152
+ retry: false,
1153
+ onSuccess: (result, variables) => {
1154
+ if (variables.event.kind === "type") {
1155
+ const sentText = variables.event.text;
1156
+ setTypeDraft((current) => (current === sentText ? "" : current));
1157
+ }
1158
+ mutationSucceeded(result.state);
1159
+ },
1160
+ onError: mutationFailed,
1161
+ });
1162
+
1163
+ const anyMutationPending =
1164
+ acquireMutation.isPending ||
1165
+ releaseMutation.isPending ||
1166
+ navigateMutation.isPending ||
1167
+ resizeMutation.isPending ||
1168
+ deviceMutation.isPending ||
1169
+ inputMutation.isPending;
1170
+ const canControl = Boolean(viewerToken && controlToken && state?.controller === "self");
1171
+ const currentFrame = frame && state && isFrameCurrent(frame, state) ? frame : null;
1172
+ const canSendInput = canControl && Boolean(currentFrame) && !inputMutation.isPending;
1173
+
1174
+ const displayRect = useMemo(
1175
+ () =>
1176
+ currentFrame
1177
+ ? containedRect(containerSize, { width: currentFrame.width, height: currentFrame.height })
1178
+ : null,
1179
+ [containerSize, currentFrame],
1180
+ );
1181
+ const frameUri = useMemo(
1182
+ () => (currentFrame ? `data:${currentFrame.mimeType};base64,${currentFrame.dataBase64}` : null),
1183
+ [currentFrame],
1184
+ );
1185
+
1186
+ const controlContext = useCallback(() => {
1187
+ const viewer = activeViewerTokenRef.current;
1188
+ const current = stateRef.current;
1189
+ if (!viewer || !controlToken || !current || current.controller !== "self") return null;
1190
+ return {
1191
+ viewerToken: viewer,
1192
+ controlToken,
1193
+ expected: {
1194
+ sessionId: current.sessionId,
1195
+ navigationGeneration: current.navigationGeneration,
1196
+ viewportGeneration: current.viewportGeneration,
1197
+ },
1198
+ };
1199
+ }, [controlToken]);
1200
+
1201
+ const inputContext = useCallback(() => {
1202
+ const context = controlContext();
1203
+ const current = stateRef.current;
1204
+ const targetFrame = frameRef.current;
1205
+ if (!context || !current || !targetFrame || !isFrameCurrent(targetFrame, current)) return null;
1206
+ return {
1207
+ ...context,
1208
+ target: {
1209
+ frameId: targetFrame.frameId,
1210
+ navigationGeneration: targetFrame.navigationGeneration,
1211
+ viewportGeneration: targetFrame.viewportGeneration,
1212
+ },
1213
+ };
1214
+ }, [controlContext]);
1215
+
1216
+ const requireControlContext = useCallback(() => {
1217
+ const context = controlContext();
1218
+ if (!context) {
1219
+ setOperationError("Take control before changing the browser.");
1220
+ return null;
1221
+ }
1222
+ return context;
1223
+ }, [controlContext]);
1224
+
1225
+ const requireInputContext = useCallback(() => {
1226
+ const context = inputContext();
1227
+ if (!context) {
1228
+ setOperationError("A current frame and active control lease are required for browser input.");
1229
+ refreshCapture();
1230
+ return null;
1231
+ }
1232
+ return context;
1233
+ }, [inputContext, refreshCapture]);
1234
+
1235
+ const sendEvent = useCallback(
1236
+ (event: BrowserInputEvent) => {
1237
+ const context = requireInputContext();
1238
+ if (!context || inputMutation.isPending) return;
1239
+ mutationEpochRef.current += 1;
1240
+ inputMutation.mutate({ ...context, event });
1241
+ },
1242
+ [inputMutation, requireInputContext],
1243
+ );
1244
+
1245
+ const pointFromEvent = useCallback(
1246
+ (event: GestureResponderEvent): DisplayPoint | null => {
1247
+ if (!displayRect) return null;
1248
+ const x = Math.min(displayRect.width, Math.max(0, event.nativeEvent.locationX));
1249
+ const y = Math.min(displayRect.height, Math.max(0, event.nativeEvent.locationY));
1250
+ return { x, y, width: displayRect.width, height: displayRect.height };
1251
+ },
1252
+ [displayRect],
1253
+ );
1254
+
1255
+ const finishPointerGesture = useCallback(
1256
+ (event: GestureResponderEvent) => {
1257
+ const start = dragStartRef.current;
1258
+ const end = pointFromEvent(event);
1259
+ dragStartRef.current = null;
1260
+ if (!start || !end) return;
1261
+ lastPointRef.current = { x: end.x, y: end.y };
1262
+ const distance = Math.hypot(end.x - start.x, end.y - start.y);
1263
+ if (distance >= DRAG_THRESHOLD) {
1264
+ if (swipeMode === "scroll") {
1265
+ const current = stateRef.current;
1266
+ const scale = current ? current.viewport.width / end.width : 1;
1267
+ sendEvent({
1268
+ kind: "scroll",
1269
+ point: { ...start, width: end.width, height: end.height },
1270
+ deltaX: clampScrollDelta((start.x - end.x) * scale),
1271
+ deltaY: clampScrollDelta((start.y - end.y) * scale),
1272
+ });
1273
+ return;
1274
+ }
1275
+ sendEvent({
1276
+ kind: "drag",
1277
+ start: { ...start, width: end.width, height: end.height },
1278
+ end,
1279
+ button: interactionMode === "right" ? "right" : "left",
1280
+ });
1281
+ return;
1282
+ }
1283
+ sendEvent({
1284
+ kind: "click",
1285
+ point: end,
1286
+ button: interactionMode === "right" ? "right" : "left",
1287
+ clickCount: interactionMode === "double" ? 2 : 1,
1288
+ });
1289
+ },
1290
+ [interactionMode, pointFromEvent, sendEvent, swipeMode],
1291
+ );
1292
+
1293
+ const panResponder = useMemo(
1294
+ () =>
1295
+ PanResponder.create({
1296
+ onStartShouldSetPanResponder: () => canSendInput,
1297
+ onMoveShouldSetPanResponder: () => canSendInput,
1298
+ onPanResponderGrant: (event) => {
1299
+ const point = pointFromEvent(event);
1300
+ dragStartRef.current = point ? { x: point.x, y: point.y } : null;
1301
+ },
1302
+ onPanResponderRelease: finishPointerGesture,
1303
+ onPanResponderTerminate: () => {
1304
+ dragStartRef.current = null;
1305
+ },
1306
+ onPanResponderTerminationRequest: () => false,
1307
+ }),
1308
+ [canSendInput, finishPointerGesture, pointFromEvent],
1309
+ );
1310
+
1311
+ const handleCanvasLayout = useCallback((event: LayoutChangeEvent) => {
1312
+ const { width, height } = event.nativeEvent.layout;
1313
+ setContainerSize((previous) =>
1314
+ previous.width === width && previous.height === height ? previous : { width, height },
1315
+ );
1316
+ }, []);
1317
+
1318
+ const takeControl = useCallback(
1319
+ (takeover: boolean) => {
1320
+ if (!viewerToken || acquireMutation.isPending) return;
1321
+ mutationEpochRef.current += 1;
1322
+ acquireMutation.mutate({ viewerToken, takeover });
1323
+ },
1324
+ [acquireMutation, viewerToken],
1325
+ );
1326
+ const release = useCallback(() => {
1327
+ const viewer = activeViewerTokenRef.current;
1328
+ if (
1329
+ !viewer ||
1330
+ !controlToken ||
1331
+ stateRef.current?.controller !== "self" ||
1332
+ releaseMutation.isPending
1333
+ ) {
1334
+ return;
1335
+ }
1336
+ mutationEpochRef.current += 1;
1337
+ releaseMutation.mutate({ viewerToken: viewer, controlToken });
1338
+ }, [controlToken, releaseMutation]);
1339
+
1340
+ const navigate = useCallback(
1341
+ (action: "back" | "forward" | "reload" | "goto", url?: string) => {
1342
+ const context = requireControlContext();
1343
+ if (!context || navigateMutation.isPending) return;
1344
+ if (action === "goto") {
1345
+ const nextUrl = url?.trim();
1346
+ if (!nextUrl) {
1347
+ setOperationError("Enter an address to navigate.");
1348
+ return;
1349
+ }
1350
+ mutationEpochRef.current += 1;
1351
+ navigateMutation.mutate({ ...context, action: { kind: "goto", url: nextUrl } });
1352
+ return;
1353
+ }
1354
+ mutationEpochRef.current += 1;
1355
+ navigateMutation.mutate({ ...context, action: { kind: action } });
1356
+ },
1357
+ [navigateMutation, requireControlContext],
1358
+ );
1359
+
1360
+ const applyViewport = useCallback(() => {
1361
+ const context = requireControlContext();
1362
+ if (!context || resizeMutation.isPending) return;
1363
+ const width = Number(viewportWidth);
1364
+ const height = Number(viewportHeight);
1365
+ if (
1366
+ !Number.isInteger(width) ||
1367
+ !Number.isInteger(height) ||
1368
+ width < MIN_VIEWPORT.width ||
1369
+ width > MAX_VIEWPORT.width ||
1370
+ height < MIN_VIEWPORT.height ||
1371
+ height > MAX_VIEWPORT.height
1372
+ ) {
1373
+ setOperationError(
1374
+ `Viewport must be ${MIN_VIEWPORT.width}–${MAX_VIEWPORT.width} × ${MIN_VIEWPORT.height}–${MAX_VIEWPORT.height}.`,
1375
+ );
1376
+ return;
1377
+ }
1378
+ mutationEpochRef.current += 1;
1379
+ resizeMutation.mutate({ ...context, viewport: { width, height } });
1380
+ }, [requireControlContext, resizeMutation, viewportHeight, viewportWidth]);
1381
+
1382
+ const selectDevicePreset = useCallback(
1383
+ (presetId: DevicePresetId) => {
1384
+ const context = requireControlContext();
1385
+ if (!context || deviceMutation.isPending) return;
1386
+ mutationEpochRef.current += 1;
1387
+ setDevicePickerOpen(false);
1388
+ deviceMutation.mutate({ ...context, presetId });
1389
+ },
1390
+ [deviceMutation, requireControlContext],
1391
+ );
1392
+
1393
+ const scroll = useCallback(
1394
+ (deltaX: number, deltaY: number) => {
1395
+ if (!displayRect) return;
1396
+ const previous = lastPointRef.current;
1397
+ const x = previous && previous.x <= displayRect.width ? previous.x : displayRect.width / 2;
1398
+ const y = previous && previous.y <= displayRect.height ? previous.y : displayRect.height / 2;
1399
+ sendEvent({
1400
+ kind: "scroll",
1401
+ point: { x, y, width: displayRect.width, height: displayRect.height },
1402
+ deltaX,
1403
+ deltaY,
1404
+ });
1405
+ },
1406
+ [displayRect, sendEvent],
1407
+ );
1408
+
1409
+ const sendText = useCallback(() => {
1410
+ if (!typeDraft || inputMutation.isPending) return;
1411
+ sendEvent({ kind: "type", text: typeDraft });
1412
+ }, [inputMutation.isPending, sendEvent, typeDraft]);
1413
+
1414
+ const reconnect = useCallback(() => {
1415
+ if (attachQuery.isFetching) return;
1416
+ mutationEpochRef.current += 1;
1417
+ setReconnecting(true);
1418
+ setControlToken(null);
1419
+ setOperationError(null);
1420
+ void attachQuery.refetch({ cancelRefetch: false }).then((result) => {
1421
+ if (!result.error) setReconnecting(false);
1422
+ });
1423
+ }, [attachQuery.isFetching, attachQuery.refetch]);
1424
+
1425
+ const connectionError = attachQuery.error ?? captureQuery.error;
1426
+ const recoveryError =
1427
+ state?.recoveryState === "runtime-unavailable"
1428
+ ? (state.error ?? "Browser runtime unavailable.")
1429
+ : null;
1430
+ const visibleError =
1431
+ operationError ??
1432
+ recoveryError ??
1433
+ state?.error ??
1434
+ (connectionError ? errorMessage(connectionError) : null);
1435
+ const statusColor =
1436
+ state?.status === "ready"
1437
+ ? theme.colors.statusSuccess
1438
+ : state?.status === "error"
1439
+ ? theme.colors.statusDanger
1440
+ : theme.colors.statusWarning;
1441
+ const statusLabel =
1442
+ state?.status === "ready" ? "Ready" : state?.status === "error" ? "Error" : "Starting";
1443
+ const leaseExpiry = state?.controllerExpiresAt
1444
+ ? new Date(state.controllerExpiresAt).toLocaleTimeString()
1445
+ : null;
1446
+ const leaseDetail = leaseExpiry ? ` · lease until ${leaseExpiry}` : "";
1447
+ const controllerLabel =
1448
+ state?.controller === "self"
1449
+ ? controlToken
1450
+ ? `You have control${leaseDetail}`
1451
+ : "Control token unavailable"
1452
+ : state?.controller === "other"
1453
+ ? `${state.controllerLabel ?? "Another viewer"} has control${leaseDetail}`
1454
+ : "Observe-only · no controller";
1455
+ const activeDevicePreset = state?.devicePresetId
1456
+ ? DEVICE_PRESETS.find(({ id }) => id === state.devicePresetId)
1457
+ : null;
1458
+ const deviceLabel = activeDevicePreset?.label ?? "Custom display";
1459
+ const transportLabel = currentFrame?.transport === "cdp-screencast" ? "CDP" : "fallback";
1460
+ const frameSummary = currentFrame
1461
+ ? layout.compact
1462
+ ? `${currentFrame.width}×${currentFrame.height} · ${transportLabel}`
1463
+ : `${currentFrame.width} × ${currentFrame.height} · ${Math.ceil(currentFrame.byteLength / BYTES_PER_KIBIBYTE)} KB · ${transportLabel} · ${deviceLabel}`
1464
+ : state
1465
+ ? `${state.viewport.width} × ${state.viewport.height} canonical`
1466
+ : "No frame";
1467
+
1468
+ let controlAction: ReactNode = null;
1469
+ if (state?.controller === "self" && controlToken) {
1470
+ controlAction = (
1471
+ <ControlButton
1472
+ styles={styles}
1473
+ theme={theme}
1474
+ label="Release"
1475
+ icon="LogOut"
1476
+ disabled={anyMutationPending}
1477
+ onPress={release}
1478
+ />
1479
+ );
1480
+ } else if (state?.controller === "other") {
1481
+ controlAction = (
1482
+ <ControlButton
1483
+ styles={styles}
1484
+ theme={theme}
1485
+ label="Take over"
1486
+ icon="Crown"
1487
+ danger
1488
+ disabled={!viewerToken || anyMutationPending}
1489
+ onPress={() => takeControl(true)}
1490
+ />
1491
+ );
1492
+ } else {
1493
+ controlAction = (
1494
+ <ControlButton
1495
+ styles={styles}
1496
+ theme={theme}
1497
+ label={state?.controller === "self" ? "Reacquire" : "Take control"}
1498
+ icon="MousePointer2"
1499
+ primary
1500
+ disabled={!viewerToken || anyMutationPending}
1501
+ onPress={() => takeControl(false)}
1502
+ />
1503
+ );
1504
+ }
1505
+
1506
+ const imageStyle = displayRect
1507
+ ? [
1508
+ styles.frame,
1509
+ {
1510
+ left: displayRect.x,
1511
+ top: displayRect.y,
1512
+ width: displayRect.width,
1513
+ height: displayRect.height,
1514
+ },
1515
+ ]
1516
+ : styles.frame;
1517
+ const interactionStyle = displayRect
1518
+ ? [
1519
+ styles.interactionLayer,
1520
+ {
1521
+ left: displayRect.x,
1522
+ top: displayRect.y,
1523
+ width: displayRect.width,
1524
+ height: displayRect.height,
1525
+ },
1526
+ ]
1527
+ : styles.interactionLayer;
1528
+
1529
+ return (
1530
+ <View style={styles.screen}>
1531
+ <View style={styles.chrome}>
1532
+ <View style={styles.addressRow}>
1533
+ <ChromeIconButton
1534
+ styles={styles}
1535
+ theme={theme}
1536
+ label="Back"
1537
+ icon="ArrowLeft"
1538
+ disabled={!canControl || !state?.canGoBack || navigateMutation.isPending}
1539
+ onPress={() => navigate("back")}
1540
+ />
1541
+ <ChromeIconButton
1542
+ styles={styles}
1543
+ theme={theme}
1544
+ label="Forward"
1545
+ icon="ArrowRight"
1546
+ disabled={!canControl || !state?.canGoForward || navigateMutation.isPending}
1547
+ onPress={() => navigate("forward")}
1548
+ />
1549
+ <ChromeIconButton
1550
+ styles={styles}
1551
+ theme={theme}
1552
+ label="Reload"
1553
+ icon="RotateCw"
1554
+ disabled={!canControl || navigateMutation.isPending}
1555
+ onPress={() => navigate("reload")}
1556
+ />
1557
+ <Field
1558
+ styles={styles}
1559
+ theme={theme}
1560
+ value={addressDraft}
1561
+ accessibilityLabel="Browser address"
1562
+ placeholder="Enter a URL"
1563
+ editable={canControl && !navigateMutation.isPending}
1564
+ maxLength={MAX_URL_LENGTH}
1565
+ returnKeyType="go"
1566
+ style={[styles.addressInput, styles.chromeAddressInput]}
1567
+ onChangeText={setAddressDraft}
1568
+ onFocus={() => setAddressFocused(true)}
1569
+ onBlur={() => setAddressFocused(false)}
1570
+ onSubmit={() => navigate("goto", addressDraft)}
1571
+ />
1572
+ <ChromeIconButton
1573
+ styles={styles}
1574
+ theme={theme}
1575
+ label={`Device: ${deviceLabel}`}
1576
+ icon={activeDevicePreset?.isMobile ? "Smartphone" : "Monitor"}
1577
+ selected={Boolean(activeDevicePreset)}
1578
+ disabled={!canControl || deviceMutation.isPending}
1579
+ onPress={() => setDevicePickerOpen(true)}
1580
+ />
1581
+ </View>
1582
+ </View>
1583
+
1584
+ <View style={styles.statusRow}>
1585
+ <View style={styles.statusSummary}>
1586
+ <View style={[styles.statusDot, { backgroundColor: statusColor }]} />
1587
+ <Text style={styles.statusText}>{state ? statusLabel : "Connecting"}</Text>
1588
+ <Text style={styles.mutedText}>
1589
+ {state
1590
+ ? `${state.viewerCount} viewer${state.viewerCount === 1 ? "" : "s"}`
1591
+ : viewerLabel}
1592
+ </Text>
1593
+ <Text numberOfLines={1} style={styles.controllerText}>
1594
+ {state ? controllerLabel : "Attaching to workspace browser"}
1595
+ </Text>
1596
+ </View>
1597
+ <View style={styles.actionRow}>{controlAction}</View>
1598
+ </View>
1599
+
1600
+ {runtimeNotice || visibleError ? (
1601
+ <ErrorNotice
1602
+ styles={styles}
1603
+ theme={theme}
1604
+ message={runtimeNotice ?? visibleError ?? ""}
1605
+ action={connectionError || reconnecting ? "Reconnect" : undefined}
1606
+ onAction={connectionError || reconnecting ? reconnect : undefined}
1607
+ actionDisabled={attachQuery.isFetching}
1608
+ />
1609
+ ) : null}
1610
+
1611
+ <View style={styles.canvasShell}>
1612
+ <View style={styles.canvas} onLayout={handleCanvasLayout}>
1613
+ {frameUri && displayRect && !imageError ? (
1614
+ <>
1615
+ <Image
1616
+ accessibilityLabel={
1617
+ state?.title ? `Shared browser: ${state.title}` : "Shared browser frame"
1618
+ }
1619
+ accessibilityRole="image"
1620
+ onError={() => {
1621
+ frameRef.current = null;
1622
+ setImageError(true);
1623
+ void captureQuery.refetch({ cancelRefetch: false });
1624
+ }}
1625
+ resizeMode="contain"
1626
+ source={{ uri: frameUri }}
1627
+ style={imageStyle}
1628
+ />
1629
+ <View
1630
+ {...panResponder.panHandlers}
1631
+ accessible={false}
1632
+ pointerEvents={canSendInput ? "auto" : "none"}
1633
+ style={interactionStyle}
1634
+ />
1635
+ </>
1636
+ ) : imageError ? (
1637
+ <CanvasPlaceholder
1638
+ styles={styles}
1639
+ theme={theme}
1640
+ title="Frame could not be displayed"
1641
+ detail="The JPEG frame was received but the client could not decode it. Capture will continue."
1642
+ />
1643
+ ) : connectionError ? (
1644
+ <CanvasPlaceholder
1645
+ styles={styles}
1646
+ theme={theme}
1647
+ title="Connection failed"
1648
+ detail="Reconnect to attach a fresh viewer and resume frame capture."
1649
+ />
1650
+ ) : state?.recoveryState === "runtime-unavailable" || state?.status === "error" ? (
1651
+ <CanvasPlaceholder
1652
+ styles={styles}
1653
+ theme={theme}
1654
+ title="Browser unavailable"
1655
+ detail={state.error ?? "The browser runtime is temporarily unavailable."}
1656
+ />
1657
+ ) : (
1658
+ <CanvasPlaceholder
1659
+ styles={styles}
1660
+ theme={theme}
1661
+ title={
1662
+ attachQuery.isPending || reconnecting
1663
+ ? "Connecting to shared browser"
1664
+ : "Waiting for frame"
1665
+ }
1666
+ detail="The browser remains active in this workspace when viewers detach."
1667
+ loading={attachQuery.isPending || reconnecting || captureQuery.isFetching}
1668
+ />
1669
+ )}
1670
+ </View>
1671
+ <View style={styles.canvasFooter}>
1672
+ <Text numberOfLines={1} style={styles.canvasFooterText}>
1673
+ {state?.title || state?.url || "Shared browser"}
1674
+ </Text>
1675
+ <Text numberOfLines={1} style={styles.canvasFooterText}>
1676
+ {frameSummary}
1677
+ </Text>
1678
+ </View>
1679
+ </View>
1680
+
1681
+ <View style={styles.controls}>
1682
+ {layout.compact ? (
1683
+ <>
1684
+ <View style={styles.mobileRow}>
1685
+ <Field
1686
+ styles={styles}
1687
+ theme={theme}
1688
+ value={typeDraft}
1689
+ accessibilityLabel="Text to type in the shared browser"
1690
+ placeholder={canSendInput ? "Type into the page" : "Take control to type"}
1691
+ editable={canSendInput}
1692
+ maxLength={MAX_TEXT_LENGTH}
1693
+ returnKeyType="send"
1694
+ style={[styles.typeInput, styles.buttonLarge]}
1695
+ onChangeText={setTypeDraft}
1696
+ onSubmit={sendText}
1697
+ />
1698
+ <ControlButton
1699
+ styles={styles}
1700
+ theme={theme}
1701
+ label="Send"
1702
+ icon="Send"
1703
+ primary
1704
+ large
1705
+ disabled={!canSendInput || !typeDraft || inputMutation.isPending}
1706
+ onPress={sendText}
1707
+ />
1708
+ </View>
1709
+ <View style={styles.mobileRow}>
1710
+ <ControlButton
1711
+ styles={styles}
1712
+ theme={theme}
1713
+ label={swipeMode === "scroll" ? "Swipe scrolls" : "Swipe drags"}
1714
+ accessibilityLabel="Pointer and scrolling options"
1715
+ icon="MousePointer2"
1716
+ large
1717
+ fill
1718
+ disabled={!canControl}
1719
+ onPress={() => setPointerSheetOpen(true)}
1720
+ />
1721
+ <ControlButton
1722
+ styles={styles}
1723
+ theme={theme}
1724
+ label="Keys"
1725
+ icon="Keyboard"
1726
+ large
1727
+ fill
1728
+ disabled={!canSendInput}
1729
+ onPress={() => setKeysSheetOpen(true)}
1730
+ />
1731
+ </View>
1732
+ </>
1733
+ ) : (
1734
+ <>
1735
+ <ScrollView
1736
+ horizontal
1737
+ keyboardShouldPersistTaps="handled"
1738
+ showsHorizontalScrollIndicator={false}
1739
+ contentContainerStyle={styles.toolbarContent}
1740
+ >
1741
+ <View style={styles.controlStrip}>
1742
+ <Text style={styles.stripLabel}>Tap</Text>
1743
+ <ControlButton
1744
+ styles={styles}
1745
+ theme={theme}
1746
+ label="Click"
1747
+ selected={interactionMode === "click"}
1748
+ disabled={!canSendInput}
1749
+ onPress={() => setInteractionMode("click")}
1750
+ />
1751
+ <ControlButton
1752
+ styles={styles}
1753
+ theme={theme}
1754
+ label="Double"
1755
+ selected={interactionMode === "double"}
1756
+ disabled={!canSendInput}
1757
+ onPress={() => setInteractionMode("double")}
1758
+ />
1759
+ <ControlButton
1760
+ styles={styles}
1761
+ theme={theme}
1762
+ label="Right"
1763
+ selected={interactionMode === "right"}
1764
+ disabled={!canSendInput}
1765
+ onPress={() => setInteractionMode("right")}
1766
+ />
1767
+ <View style={styles.separator} />
1768
+ <Text style={styles.stripLabel}>Drag</Text>
1769
+ <ControlButton
1770
+ styles={styles}
1771
+ theme={theme}
1772
+ label="Scrolls"
1773
+ selected={swipeMode === "scroll"}
1774
+ disabled={!canControl}
1775
+ onPress={() => setSwipeMode("scroll")}
1776
+ />
1777
+ <ControlButton
1778
+ styles={styles}
1779
+ theme={theme}
1780
+ label="Drags"
1781
+ selected={swipeMode === "drag"}
1782
+ disabled={!canControl}
1783
+ onPress={() => setSwipeMode("drag")}
1784
+ />
1785
+ <View style={styles.separator} />
1786
+ <Text style={styles.stripLabel}>Scroll</Text>
1787
+ <ControlButton
1788
+ styles={styles}
1789
+ theme={theme}
1790
+ label="←"
1791
+ accessibilityLabel="Scroll left"
1792
+ disabled={!canSendInput}
1793
+ onPress={() => scroll(-SCROLL_STEP, 0)}
1794
+ />
1795
+ <ControlButton
1796
+ styles={styles}
1797
+ theme={theme}
1798
+ label="↑"
1799
+ accessibilityLabel="Scroll up"
1800
+ disabled={!canSendInput}
1801
+ onPress={() => scroll(0, -SCROLL_STEP)}
1802
+ />
1803
+ <ControlButton
1804
+ styles={styles}
1805
+ theme={theme}
1806
+ label="↓"
1807
+ accessibilityLabel="Scroll down"
1808
+ disabled={!canSendInput}
1809
+ onPress={() => scroll(0, SCROLL_STEP)}
1810
+ />
1811
+ <ControlButton
1812
+ styles={styles}
1813
+ theme={theme}
1814
+ label="→"
1815
+ accessibilityLabel="Scroll right"
1816
+ disabled={!canSendInput}
1817
+ onPress={() => scroll(SCROLL_STEP, 0)}
1818
+ />
1819
+ </View>
1820
+ </ScrollView>
1821
+
1822
+ <View style={styles.typeRow}>
1823
+ <Field
1824
+ styles={styles}
1825
+ theme={theme}
1826
+ value={typeDraft}
1827
+ accessibilityLabel="Text to type in the shared browser"
1828
+ placeholder={
1829
+ canSendInput
1830
+ ? "Type into the focused page element"
1831
+ : "Take control and focus a page field"
1832
+ }
1833
+ editable={canSendInput}
1834
+ maxLength={MAX_TEXT_LENGTH}
1835
+ returnKeyType="send"
1836
+ style={styles.typeInput}
1837
+ onChangeText={setTypeDraft}
1838
+ onSubmit={sendText}
1839
+ />
1840
+ <ControlButton
1841
+ styles={styles}
1842
+ theme={theme}
1843
+ label="Send"
1844
+ icon="Send"
1845
+ primary
1846
+ disabled={!canSendInput || !typeDraft || inputMutation.isPending}
1847
+ onPress={sendText}
1848
+ />
1849
+ </View>
1850
+ <ScrollView
1851
+ horizontal
1852
+ keyboardShouldPersistTaps="handled"
1853
+ showsHorizontalScrollIndicator={false}
1854
+ contentContainerStyle={styles.keysContent}
1855
+ >
1856
+ <Text style={styles.stripLabel}>Keys</Text>
1857
+ {SPECIAL_KEYS.map(({ key, label }) => (
1858
+ <ControlButton
1859
+ key={key}
1860
+ styles={styles}
1861
+ theme={theme}
1862
+ label={label}
1863
+ accessibilityLabel={`Send ${key} key`}
1864
+ disabled={!canSendInput}
1865
+ onPress={() => sendEvent({ kind: "key", key })}
1866
+ />
1867
+ ))}
1868
+ </ScrollView>
1869
+ </>
1870
+ )}
1871
+ </View>
1872
+
1873
+ <Modal
1874
+ title="Pointer and scrolling"
1875
+ icon={<Icon name="MousePointer2" size={18} color={theme.colors.foreground} />}
1876
+ open={pointerSheetOpen}
1877
+ onOpenChange={setPointerSheetOpen}
1878
+ >
1879
+ <Modal.Content>
1880
+ <View style={styles.sheetContent}>
1881
+ <Text style={styles.stripLabel}>Swipe gesture</Text>
1882
+ <View style={styles.sheetGrid}>
1883
+ <ControlButton
1884
+ styles={styles}
1885
+ theme={theme}
1886
+ label="Scrolls the page"
1887
+ large
1888
+ fill
1889
+ selected={swipeMode === "scroll"}
1890
+ disabled={!canControl}
1891
+ onPress={() => setSwipeMode("scroll")}
1892
+ />
1893
+ <ControlButton
1894
+ styles={styles}
1895
+ theme={theme}
1896
+ label="Drags content"
1897
+ large
1898
+ fill
1899
+ selected={swipeMode === "drag"}
1900
+ disabled={!canControl}
1901
+ onPress={() => setSwipeMode("drag")}
1902
+ />
1903
+ </View>
1904
+ <Text style={styles.stripLabel}>Tap action</Text>
1905
+ <View style={styles.sheetGrid}>
1906
+ <ControlButton
1907
+ styles={styles}
1908
+ theme={theme}
1909
+ label="Click"
1910
+ large
1911
+ fill
1912
+ selected={interactionMode === "click"}
1913
+ disabled={!canControl}
1914
+ onPress={() => setInteractionMode("click")}
1915
+ />
1916
+ <ControlButton
1917
+ styles={styles}
1918
+ theme={theme}
1919
+ label="Double"
1920
+ large
1921
+ fill
1922
+ selected={interactionMode === "double"}
1923
+ disabled={!canControl}
1924
+ onPress={() => setInteractionMode("double")}
1925
+ />
1926
+ <ControlButton
1927
+ styles={styles}
1928
+ theme={theme}
1929
+ label="Right"
1930
+ large
1931
+ fill
1932
+ selected={interactionMode === "right"}
1933
+ disabled={!canControl}
1934
+ onPress={() => setInteractionMode("right")}
1935
+ />
1936
+ </View>
1937
+ <Text style={styles.stripLabel}>Nudge scroll</Text>
1938
+ <View style={styles.sheetPad}>
1939
+ <ControlButton
1940
+ styles={styles}
1941
+ theme={theme}
1942
+ label="↑"
1943
+ accessibilityLabel="Scroll up"
1944
+ pad
1945
+ disabled={!canSendInput}
1946
+ onPress={() => scroll(0, -SCROLL_STEP)}
1947
+ />
1948
+ <View style={styles.sheetPadRow}>
1949
+ <ControlButton
1950
+ styles={styles}
1951
+ theme={theme}
1952
+ label="←"
1953
+ accessibilityLabel="Scroll left"
1954
+ pad
1955
+ disabled={!canSendInput}
1956
+ onPress={() => scroll(-SCROLL_STEP, 0)}
1957
+ />
1958
+ <View style={styles.padSpacer} />
1959
+ <ControlButton
1960
+ styles={styles}
1961
+ theme={theme}
1962
+ label="→"
1963
+ accessibilityLabel="Scroll right"
1964
+ pad
1965
+ disabled={!canSendInput}
1966
+ onPress={() => scroll(SCROLL_STEP, 0)}
1967
+ />
1968
+ </View>
1969
+ <ControlButton
1970
+ styles={styles}
1971
+ theme={theme}
1972
+ label="↓"
1973
+ accessibilityLabel="Scroll down"
1974
+ pad
1975
+ disabled={!canSendInput}
1976
+ onPress={() => scroll(0, SCROLL_STEP)}
1977
+ />
1978
+ </View>
1979
+ <Text style={styles.devicePresetDetail}>
1980
+ Tap the page to click. Swipe to scroll or drag, depending on the gesture above.
1981
+ </Text>
1982
+ </View>
1983
+ </Modal.Content>
1984
+ </Modal>
1985
+
1986
+ <Modal
1987
+ title="Keyboard keys"
1988
+ icon={<Icon name="Keyboard" size={18} color={theme.colors.foreground} />}
1989
+ open={keysSheetOpen}
1990
+ onOpenChange={setKeysSheetOpen}
1991
+ >
1992
+ <Modal.Content>
1993
+ <View style={styles.sheetContent}>
1994
+ <View style={styles.sheetGrid}>
1995
+ {SPECIAL_KEYS.map(({ key, label }) => (
1996
+ <ControlButton
1997
+ key={key}
1998
+ styles={styles}
1999
+ theme={theme}
2000
+ label={label}
2001
+ accessibilityLabel={`Send ${key} key`}
2002
+ large
2003
+ disabled={!canSendInput}
2004
+ onPress={() => sendEvent({ kind: "key", key })}
2005
+ />
2006
+ ))}
2007
+ </View>
2008
+ <Text style={styles.devicePresetDetail}>
2009
+ Keys go to the page element that currently has focus in the shared browser.
2010
+ </Text>
2011
+ </View>
2012
+ </Modal.Content>
2013
+ </Modal>
2014
+ <Modal
2015
+ title="Device emulation"
2016
+ icon={<Icon name="Smartphone" size={18} color={theme.colors.foreground} />}
2017
+ open={devicePickerOpen}
2018
+ onOpenChange={setDevicePickerOpen}
2019
+ >
2020
+ <Modal.Content>
2021
+ <View style={styles.deviceModalContent}>
2022
+ {DEVICE_PRESETS.map((preset) => {
2023
+ const selected = state?.devicePresetId === preset.id;
2024
+ return (
2025
+ <Pressable
2026
+ key={preset.id}
2027
+ accessibilityRole="button"
2028
+ accessibilityLabel={`Emulate ${preset.label}`}
2029
+ accessibilityState={{ selected }}
2030
+ disabled={!canControl || deviceMutation.isPending}
2031
+ onPress={() => selectDevicePreset(preset.id)}
2032
+ style={({ pressed }) => [
2033
+ styles.devicePresetRow,
2034
+ selected ? styles.devicePresetRowSelected : null,
2035
+ pressed ? styles.buttonPressed : null,
2036
+ !canControl ? styles.buttonDisabled : null,
2037
+ ]}
2038
+ >
2039
+ <Icon
2040
+ name={preset.isMobile ? "Smartphone" : "Monitor"}
2041
+ size={18}
2042
+ color={selected ? theme.colors.accent : theme.colors.foregroundMuted}
2043
+ />
2044
+ <Text style={styles.devicePresetText}>{preset.label}</Text>
2045
+ <Text style={styles.devicePresetDetail}>
2046
+ {preset.viewport.width} × {preset.viewport.height}
2047
+ </Text>
2048
+ </Pressable>
2049
+ );
2050
+ })}
2051
+ <Text style={styles.stripLabel}>Custom viewport</Text>
2052
+ <View style={styles.customViewportRow}>
2053
+ <Field
2054
+ styles={styles}
2055
+ theme={theme}
2056
+ value={viewportWidth}
2057
+ accessibilityLabel="Canonical viewport width"
2058
+ editable={canControl && !resizeMutation.isPending}
2059
+ keyboardType="number-pad"
2060
+ inputMode="numeric"
2061
+ maxLength={4}
2062
+ selectTextOnFocus
2063
+ style={styles.viewportField}
2064
+ onChangeText={setViewportWidth}
2065
+ onSubmit={applyViewport}
2066
+ />
2067
+ <Text style={styles.multiply}>×</Text>
2068
+ <Field
2069
+ styles={styles}
2070
+ theme={theme}
2071
+ value={viewportHeight}
2072
+ accessibilityLabel="Canonical viewport height"
2073
+ editable={canControl && !resizeMutation.isPending}
2074
+ keyboardType="number-pad"
2075
+ inputMode="numeric"
2076
+ maxLength={4}
2077
+ selectTextOnFocus
2078
+ style={styles.viewportField}
2079
+ onChangeText={setViewportHeight}
2080
+ onSubmit={applyViewport}
2081
+ />
2082
+ <ControlButton
2083
+ styles={styles}
2084
+ theme={theme}
2085
+ label="Apply"
2086
+ disabled={!canControl || resizeMutation.isPending}
2087
+ onPress={applyViewport}
2088
+ />
2089
+ </View>
2090
+ <Text style={styles.devicePresetDetail}>
2091
+ Presets change viewport, touch behavior, and user agent. Rendering remains Chromium.
2092
+ </Text>
2093
+ </View>
2094
+ </Modal.Content>
2095
+ </Modal>
2096
+ </View>
2097
+ );
2098
+ }