main
js 3,328 lines 116 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import { callJsonApi } from "/js/api.js";
3 import { getNamespacedClient } from "/js/websocket.js";
4 import { getContext, setContext } from "/index.js";
5 import { copyToClipboard } from "/components/messages/action-buttons/simple-action-buttons.js";
6 import { store as chatInputStore } from "/components/chat/input/input-store.js";
7 import { store as pluginSettingsStore } from "/components/plugins/plugin-settings-store.js";
8 import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
9 import { store as rightCanvasStore } from "/components/canvas/right-canvas-store.js";
10 import {
11 openLatest as openLatestSurface,
12 placeSurfaceModalHeaderAction,
13 registerUrlHandler,
14 } from "/js/surfaces.js";
15
16 const websocket = getNamespacedClient("/ws");
17 websocket.addHandlers(["ws_webui"]);
18
19 const EXTENSIONS_ROOT = "/a0/usr/_browser/extensions";
20 const BROWSER_SUBSCRIBE_TIMEOUT_MS = 60000;
21 const BROWSER_FIRST_INSTALL_TIMEOUT_MS = 300000;
22 const BROWSER_COMMAND_TIMEOUT_MS = 45000;
23 const BROWSER_CONFIG_REFRESH_MS = 15000;
24 const BROWSER_VIEWER_TRANSPORT_INTERACTIVE = "interactive";
25 const BROWSER_VIEWER_TRANSPORT_SNAPSHOT = "snapshot";
26 const BROWSER_VIEWER_TRANSPORT_SCREENCAST = "screencast";
27 const VIEWPORT_SYNC_INTERVAL_MS = 50;
28 const VIEWPORT_SYNC_SIZE_TOLERANCE = 4;
29 const CANVAS_VIEWPORT_SETTLE_MS = 520;
30 const INTERACTIVE_VIEWPORT_SETTLE_MS = 320;
31 const SURFACE_VIEWPORT_STABLE_FRAMES = 4;
32 const SURFACE_VIEWPORT_MAX_WAIT_MS = 1200;
33 const FRAME_REJECT_SYNC_COOLDOWN_MS = 600;
34 const ANNOTATION_DRAG_THRESHOLD = 6;
35 const ANNOTATION_MAX_COMMENTS = 24;
36 const ANNOTATION_DOM_LIMIT = 1200;
37 const ANNOTATION_TRAY_MARGIN = 10;
38 const BROWSER_VISUAL_SHORTCUT_KEYS = new Set(["a", "c", "insert", "v", "x", "y", "z"]);
39 const LOCAL_EDITABLE_SELECTOR = "input, textarea, select, [contenteditable]";
40 const BROWSER_BINARY_FRAME_REQUESTS_ENABLED = false;
41 const BROWSER_BINARY_PAYLOADS_SUPPORTED = typeof Blob === "function"
42 && typeof globalThis.URL?.createObjectURL === "function";
43 const BROWSER_CANVAS_FRAMES_SUPPORTED = typeof globalThis.createImageBitmap === "function";
44
45 function makeViewerToken() {
46 return globalThis.crypto?.randomUUID?.()
47 || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
48 }
49
50 function firstOk(response) {
51 const result = response?.results?.find((item) => item?.ok);
52 if (result) {
53 const data = result.data || {};
54 if (data.browser_error) {
55 throw new Error(data.browser_error.error || data.browser_error.code || "Browser request failed");
56 }
57 return data;
58 }
59 const error = response?.results?.find((item) => !item?.ok)?.error;
60 if (error) throw new Error(error.error || error.code || "Browser request failed");
61 return {};
62 }
63
64 function normalizeBool(value, fallback = true) {
65 if (value === undefined || value === null || value === "") return fallback;
66 if (typeof value === "boolean") return value;
67 if (typeof value === "number") return Boolean(value);
68 const normalized = String(value).trim().toLowerCase();
69 if (["1", "true", "yes", "on", "enabled"].includes(normalized)) return true;
70 if (["0", "false", "no", "off", "disabled"].includes(normalized)) return false;
71 return fallback;
72 }
73
74 function elementFromTarget(target) {
75 if (!target) return null;
76 if (target.nodeType === 1) return target;
77 return target.parentElement || null;
78 }
79
80 function isLocalEditableTarget(target) {
81 const element = elementFromTarget(target);
82 const editable = element?.closest?.(LOCAL_EDITABLE_SELECTOR);
83 if (!editable) return false;
84 if (editable.matches?.("input, textarea, select")) return true;
85 const value = String(editable.getAttribute?.("contenteditable") || "").trim().toLowerCase();
86 return ["", "true", "plaintext-only"].includes(value);
87 }
88
89 function isAltTextInput(event, platform = "") {
90 const key = String(event?.key || "");
91 if (key.length !== 1 || !event?.altKey || event.metaKey) return false;
92 const targetPlatform = String(
93 platform
94 || globalThis.navigator?.userAgentData?.platform
95 || globalThis.navigator?.platform
96 || "",
97 );
98 return Boolean(
99 event.ctrlKey
100 || event.getModifierState?.("AltGraph")
101 || /mac/i.test(targetPlatform),
102 );
103 }
104
105 function nextAnimationFrame() {
106 return new Promise((resolve) => {
107 const schedule = globalThis.requestAnimationFrame || ((callback) => globalThis.setTimeout(callback, 16));
108 schedule(() => resolve());
109 });
110 }
111
112 function loadFrameDimensions(src) {
113 return new Promise((resolve) => {
114 if (!src) {
115 resolve(null);
116 return;
117 }
118
119 const image = new Image();
120 let settled = false;
121 const finish = (dimensions) => {
122 if (settled) return;
123 settled = true;
124 resolve(dimensions);
125 };
126
127 image.onload = () => finish({
128 width: image.naturalWidth || 0,
129 height: image.naturalHeight || 0,
130 });
131 image.onerror = () => finish(null);
132 image.src = src;
133
134 if (image.complete) {
135 image.onload();
136 }
137 });
138 }
139
140 function frameImageSource(data = {}) {
141 const image = data?.image;
142 if (!image) return null;
143 const mime = data.mime || "image/jpeg";
144 const isArrayBuffer = typeof ArrayBuffer !== "undefined" && image instanceof ArrayBuffer;
145 const isView = typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView?.(image);
146 const isBlob = typeof Blob !== "undefined" && image instanceof Blob;
147 if (isArrayBuffer || isView || isBlob) {
148 if (!BROWSER_BINARY_PAYLOADS_SUPPORTED) return null;
149 const blob = isBlob ? image : new Blob([image], { type: mime });
150 const src = globalThis.URL.createObjectURL(blob);
151 return {
152 src,
153 blob,
154 objectUrl: src,
155 cleanup: () => globalThis.URL.revokeObjectURL(src),
156 };
157 }
158 if (data.encoding === "binary") return null;
159 if (typeof image !== "string") return null;
160 return {
161 src: `data:${mime};base64,${image}`,
162 objectUrl: "",
163 cleanup: null,
164 };
165 }
166
167 async function loadFrameBitmap(src, options = {}) {
168 if (!BROWSER_CANVAS_FRAMES_SUPPORTED || !src) return null;
169 try {
170 const blob = options.blob || await fetch(src).then((response) => response.blob());
171 return await globalThis.createImageBitmap(blob);
172 } catch {
173 return null;
174 }
175 }
176
177 const model = {
178 loading: true,
179 error: "",
180 status: null,
181 contextId: "",
182 browsers: [],
183 activeBrowserId: null,
184 activeBrowserContextId: "",
185 address: "",
186 frameSrc: "",
187 frameCanvasReady: false,
188 frameState: null,
189 viewerTransport: BROWSER_VIEWER_TRANSPORT_INTERACTIVE,
190 interactiveViewUrl: "",
191 viewerFallbackReason: "",
192 tabScope: "per_context",
193 annotating: false,
194 annotationComments: [],
195 annotationHover: null,
196 annotationDraft: null,
197 annotationDraftText: "",
198 annotationDragRect: null,
199 annotationBusy: false,
200 annotationError: "",
201 annotationTrayPosition: null,
202 annotationTrayDragging: false,
203 connected: false,
204 switchingBrowserId: null,
205 commandInFlight: false,
206 addressFocused: false,
207 _frameOff: null,
208 _stateOff: null,
209 _lastFrameAt: 0,
210 _lastFrameDimensions: null,
211 _pendingFrameSrc: "",
212 _pendingFrameOptions: null,
213 _frameObjectUrl: "",
214 _frameRenderHandle: null,
215 _frameRenderCancel: null,
216 _frameRenderSequence: 0,
217 _frameCanvas: null,
218 _floatingCleanup: null,
219 _stageElement: null,
220 _stageResizeObserver: null,
221 _viewportSyncTimer: null,
222 _lastFrameRejectSyncAt: 0,
223 _lastViewportKey: "",
224 _lastViewport: null,
225 _annotationPointer: null,
226 _annotationTrayDrag: null,
227 _annotationSequence: 0,
228 _annotationHoverSequence: 0,
229 _annotationHoverAt: 0,
230 _mode: "",
231 _surfaceMounted: false,
232 _surfaceSwitching: false,
233 _surfaceHandoff: false,
234 _surfaceHandoffTimer: null,
235 _surfaceOpenedAt: 0,
236 _surfaceOpenSequence: 0,
237 _openPromise: null,
238 _openSignature: "",
239 _connectSequence: 0,
240 _viewerToken: "",
241 _subscribedViewerTransport: BROWSER_VIEWER_TRANSPORT_INTERACTIVE,
242 _contextCreatePromise: null,
243 _lastSelectedContextId: "",
244 _sessionRefreshPromise: null,
245 _sessionRefreshContextId: "",
246 extensionMenuOpen: false,
247 extensionInstallUrl: "",
248 extensionActionLoading: false,
249 extensionActionMessage: "",
250 extensionActionError: "",
251 extensionsRoot: "",
252 extensionsList: [],
253 extensionsListLoading: false,
254 extensionToggleLoadingPath: "",
255 modelPreset: "",
256 modelPresetOptions: [],
257 mainModelSummary: "",
258 modelPresetSaving: false,
259 browserInstallExpected: false,
260 defaultHomepage: "about:blank",
261 autofocusActivePage: true,
262 _commandInFlightCount: 0,
263 _closingBrowserIds: {},
264 _configLoadedAt: 0,
265 _configRefreshPromise: null,
266 _clipboardFallbackText: "",
267
268 async refreshStatus() {
269 this.status = await callJsonApi("/plugins/_browser/status", {});
270 this.browserInstallExpected = Boolean(this.status?.playwright?.install_required);
271 },
272
273 async refreshExtensionsList() {
274 this.extensionsListLoading = true;
275 try {
276 const response = await callJsonApi("/plugins/_browser/extensions", {
277 action: "list",
278 context_id: this.resolveContextId() || this.contextId,
279 });
280 if (!response?.ok) {
281 throw new Error(response?.error || "Could not load browser extensions.");
282 }
283 this.applyExtensionPayload(response);
284 } catch (error) {
285 this.extensionActionError = error instanceof Error ? error.message : String(error);
286 } finally {
287 this.extensionsListLoading = false;
288 }
289 },
290
291 applyExtensionPayload(response = {}) {
292 this.extensionsRoot = response.root || EXTENSIONS_ROOT;
293 this.extensionsList = Array.isArray(response.extensions) ? response.extensions : [];
294 this.defaultHomepage = String(response.default_homepage || "about:blank").trim() || "about:blank";
295 this.autofocusActivePage = normalizeBool(response.autofocus_active_page, true);
296 this.modelPreset = String(response.model_preset || "");
297 this.mainModelSummary = String(response.main_model_summary || "");
298 this.modelPresetOptions = Array.isArray(response.model_preset_options)
299 ? response.model_preset_options
300 : [];
301 this._configLoadedAt = Date.now();
302 },
303
304 async ensureBrowserConfigLoaded(force = false) {
305 if (!force && this._configLoadedAt && Date.now() - this._configLoadedAt < BROWSER_CONFIG_REFRESH_MS) {
306 return;
307 }
308 if (this._configRefreshPromise) {
309 await this._configRefreshPromise;
310 return;
311 }
312 this._configRefreshPromise = (async () => {
313 const response = await callJsonApi("/plugins/_browser/extensions", {
314 action: "list",
315 context_id: this.resolveContextId() || this.contextId,
316 });
317 if (!response?.ok) {
318 throw new Error(response?.error || "Could not load browser settings.");
319 }
320 this.applyExtensionPayload(response);
321 })();
322 try {
323 await this._configRefreshPromise;
324 } finally {
325 this._configRefreshPromise = null;
326 }
327 },
328
329 async allowsToolAutofocus() {
330 try {
331 await this.ensureBrowserConfigLoaded();
332 } catch (error) {
333 console.warn("Browser autofocus setting could not be loaded", error);
334 }
335 return this.autofocusActivePage !== false;
336 },
337
338 handleSelectedContextChange(contextId = "") {
339 const selectedContextId = this.normalizeContextId(contextId || this.resolveContextId());
340 if (selectedContextId === this._lastSelectedContextId) return;
341 this._lastSelectedContextId = selectedContextId;
342 if (!this._surfaceMounted) return;
343 void this.syncViewerToSelectedContext(selectedContextId);
344 },
345
346 async refreshBrowserSessions(contextId = "") {
347 const requestedContextId = this.normalizeContextId(contextId || this.resolveContextId());
348 if (this._sessionRefreshPromise) {
349 const inFlightContextId = this._sessionRefreshContextId;
350 await this._sessionRefreshPromise;
351 if (requestedContextId && requestedContextId !== inFlightContextId) {
352 return await this.refreshBrowserSessions(requestedContextId);
353 }
354 return;
355 }
356 this._sessionRefreshContextId = requestedContextId;
357 this._sessionRefreshPromise = (async () => {
358 const response = await websocket.request(
359 "browser_viewer_sessions",
360 { context_id: requestedContextId },
361 { timeoutMs: 10000 },
362 );
363 const data = firstOk(response);
364 this.applyTabScope(data);
365 this.applyBrowserListing(data.browsers || [], data.context_id || "", {
366 replaceAll: Boolean(data.all_browsers),
367 replaceContext: !data.all_browsers,
368 });
369 })();
370 try {
371 await this._sessionRefreshPromise;
372 } catch (error) {
373 console.warn("Browser session refresh failed", error);
374 } finally {
375 this._sessionRefreshPromise = null;
376 this._sessionRefreshContextId = "";
377 }
378 },
379
380 async syncViewerToSelectedContext(contextId = "") {
381 const selectedContextId = this.normalizeContextId(contextId || this.resolveContextId());
382 if (!selectedContextId) return;
383 await this.refreshBrowserSessions(selectedContextId);
384 if (!this._surfaceMounted || !this.isVisibleBrowserSurface()) return;
385
386 const targetBrowserId = this.firstBrowserInContext(selectedContextId)?.id || null;
387 if (
388 this.normalizeContextId(this.contextId) === selectedContextId
389 && (
390 !targetBrowserId
391 || this.sameBrowserTab(targetBrowserId, selectedContextId, this.activeBrowserId, this.activeBrowserContextId)
392 )
393 ) {
394 return;
395 }
396
397 this.loading = true;
398 this.error = "";
399 this.resetRenderedFrame();
400 this.resetViewportTracking();
401 this._surfaceSwitching = Boolean(targetBrowserId);
402 this.switchingBrowserId = targetBrowserId;
403 try {
404 await this.connectViewer({
405 browserId: targetBrowserId,
406 contextId: selectedContextId,
407 initialViewport: this.currentViewportSize(),
408 });
409 await this.syncViewportAfterSurfaceOpen(this._surfaceOpenSequence);
410 } catch (error) {
411 this.error = error instanceof Error ? error.message : String(error);
412 } finally {
413 this.loading = false;
414 this._surfaceSwitching = false;
415 }
416 },
417
418 toggleExtensionsMenu() {
419 this.extensionMenuOpen = !this.extensionMenuOpen;
420 if (this.extensionMenuOpen) {
421 this.extensionActionMessage = "";
422 this.extensionActionError = "";
423 void this.refreshExtensionsList();
424 }
425 },
426
427 closeExtensionsMenu() {
428 this.extensionMenuOpen = false;
429 },
430
431 resolveContextId() {
432 const urlContext = new URLSearchParams(globalThis.location?.search || "").get("ctxid");
433 return getContext() || urlContext || chatsStore.selected || "";
434 },
435
436 normalizeContextId(contextId = "") {
437 return String(contextId || "").trim();
438 },
439
440 async ensureContextId() {
441 const existingContextId = String(this.resolveContextId() || "").trim();
442 if (existingContextId) {
443 this.contextId = existingContextId;
444 return existingContextId;
445 }
446
447 if (!this._contextCreatePromise) {
448 this._contextCreatePromise = this.createChatContextForBrowser();
449 }
450
451 try {
452 const contextId = await this._contextCreatePromise;
453 this.contextId = contextId;
454 return contextId;
455 } finally {
456 this._contextCreatePromise = null;
457 }
458 },
459
460 async contextIdForNewBrowser() {
461 const selectedContextId = this.normalizeContextId(chatsStore.selected);
462 if (selectedContextId) {
463 this.contextId = selectedContextId;
464 return selectedContextId;
465 }
466 return await this.ensureContextId();
467 },
468
469 async contextIdForActiveBrowser() {
470 const activeContextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
471 if (activeContextId) return activeContextId;
472 return await this.ensureContextId();
473 },
474
475 async createChatContextForBrowser() {
476 const response = await callJsonApi("/chat_create", {
477 current_context: this.resolveContextId() || "",
478 });
479 const selectedContextId = String(this.resolveContextId() || "").trim();
480 if (selectedContextId) return selectedContextId;
481
482 const contextId = String(response?.ctxid || "").trim();
483 if (!response?.ok || !contextId) {
484 throw new Error(response?.error || "Could not create a chat for Browser.");
485 }
486
487 setContext(contextId);
488 chatsStore.setSelected?.(contextId);
489
490 return contextId;
491 },
492
493 async openExtensionsSettings() {
494 if (!pluginSettingsStore?.openConfig) {
495 this.error = "Browser settings are unavailable.";
496 return;
497 }
498 try {
499 this.closeExtensionsMenu();
500 await pluginSettingsStore.openConfig("_browser");
501 await this.refreshAfterSettingsClose();
502 } catch (error) {
503 this.error = error instanceof Error ? error.message : String(error);
504 }
505 },
506
507 async refreshAfterSettingsClose() {
508 this.loading = true;
509 this.error = "";
510 try {
511 await this.refreshStatus();
512 await this.refreshExtensionsList();
513 this.connected = false;
514 this.browsers = [];
515 this.setActiveBrowserId(null);
516 this.address = "";
517 this.frameState = null;
518 this.clearFrameSrc();
519 this.clearFrameCanvas();
520 if (this.contextId) {
521 await this.connectViewer();
522 }
523 } finally {
524 this.loading = false;
525 }
526 },
527
528 createExtensionWithAgent() {
529 this._prefillAgentPrompt(
530 [
531 "Use the browser-extension-control skill to create a new Chrome extension for Agent Zero's Browser.",
532 "Start by asking me for the extension name, purpose, target websites, and required permissions.",
533 `Create it under ${this.extensionsRoot || EXTENSIONS_ROOT}/<extension-slug> and keep permissions minimal.`,
534 ].join("\n")
535 );
536 },
537
538 askAgentInstallExtension() {
539 const url = String(this.extensionInstallUrl || "").trim();
540 const prompt = url
541 ? [
542 "Use the browser-extension-control skill to review and optionally install this Chrome Web Store extension for Agent Zero's Browser.",
543 `Chrome Web Store URL or id: ${url}`,
544 "Explain the permissions and any sandbox risk before enabling it.",
545 ].join("\n")
546 : [
547 "Use the browser-extension-control skill to help me install and review a Chrome Web Store extension for Agent Zero's Browser.",
548 "Ask me for the Chrome Web Store URL or extension id first.",
549 "Explain the permissions and any sandbox risk before enabling it.",
550 ].join("\n");
551 this._prefillAgentPrompt(prompt);
552 },
553
554 async installExtensionFromUrl() {
555 const url = String(this.extensionInstallUrl || "").trim();
556 this.extensionActionMessage = "";
557 this.extensionActionError = "";
558 if (!url) {
559 this.extensionActionError = "Paste a Chrome Web Store URL or extension id first.";
560 return;
561 }
562
563 this.extensionActionLoading = true;
564 this.extensionActionMessage = "Installing extension… Large packages may take a few minutes.";
565 try {
566 const response = await callJsonApi("/plugins/_browser/extensions", {
567 action: "install_web_store",
568 context_id: this.resolveContextId() || this.contextId,
569 url,
570 });
571 if (!response?.ok) {
572 throw new Error(response?.error || "Install failed.");
573 }
574 this.applyExtensionPayload(response);
575 this.extensionInstallUrl = "";
576 this.extensionActionMessage = `Installed ${response.name || response.id}.`;
577 await this.refreshAfterSettingsClose();
578 } catch (error) {
579 this.extensionActionMessage = "";
580 this.extensionActionError = error instanceof Error ? error.message : String(error);
581 } finally {
582 this.extensionActionLoading = false;
583 }
584 },
585
586 async setExtensionEnabled(extension, enabled, input = null) {
587 const path = String(extension?.path || "");
588 if (!path) return;
589 const previous = Boolean(extension?.enabled);
590 this.extensionActionMessage = "";
591 this.extensionActionError = "";
592 this.extensionToggleLoadingPath = path;
593 try {
594 const response = await callJsonApi("/plugins/_browser/extensions", {
595 action: "set_extension_enabled",
596 context_id: this.resolveContextId() || this.contextId,
597 path,
598 enabled: Boolean(enabled),
599 });
600 if (!response?.ok) {
601 throw new Error(response?.error || "Could not update extension.");
602 }
603 this.applyExtensionPayload(response);
604 this.extensionActionMessage = `${enabled ? "Enabled" : "Disabled"} ${extension.name || "extension"}.`;
605 await this.refreshAfterSettingsClose();
606 } catch (error) {
607 if (input) input.checked = previous;
608 this.extensionActionError = error instanceof Error ? error.message : String(error);
609 } finally {
610 this.extensionToggleLoadingPath = "";
611 }
612 },
613
614 async setBrowserModelPreset(value) {
615 const presetName = String(value || "");
616 this.modelPreset = presetName;
617 this.extensionActionMessage = "";
618 this.extensionActionError = "";
619 this.modelPresetSaving = true;
620 try {
621 const response = await callJsonApi("/plugins/_browser/extensions", {
622 action: "set_model_preset",
623 context_id: this.resolveContextId() || this.contextId,
624 model_preset: presetName,
625 });
626 if (!response?.ok) {
627 throw new Error(response?.error || "Could not update browser model preset.");
628 }
629 this.applyExtensionPayload(response);
630 this.extensionActionMessage = "Browser model preset updated.";
631 } catch (error) {
632 this.extensionActionError = error instanceof Error ? error.message : String(error);
633 await this.refreshExtensionsList();
634 } finally {
635 this.modelPresetSaving = false;
636 }
637 },
638
639 modelPresetSummary() {
640 if (!this.modelPreset) {
641 return this.mainModelSummary ? `Using ${this.mainModelSummary}` : "Using Main Model";
642 }
643 const option = this.modelPresetOptions.find((preset) => preset?.name === this.modelPreset);
644 return option?.summary || option?.label || this.modelPreset;
645 },
646
647 hasExtensionInstallUrl() {
648 return Boolean(String(this.extensionInstallUrl || "").trim());
649 },
650
651 extensionAssistantActionLabel() {
652 return "Scan with A0";
653 },
654
655 extensionVersionLabel(extension) {
656 const version = String(extension?.version || "").trim();
657 return version ? `v${version}` : "Unpacked extension";
658 },
659
660 extensionOpenUrl(extension) {
661 return String(extension?.open_url || extension?.ui?.open_url || "").trim();
662 },
663
664 extensionHasOpenUi(extension) {
665 return Boolean(this.extensionOpenUrl(extension));
666 },
667
668 extensionOpenTitle(extension) {
669 const label = String(extension?.open_label || extension?.ui?.open_label || "Extension UI").trim();
670 const name = String(extension?.name || "extension").trim();
671 if (!extension?.enabled) {
672 return `Enable ${name} before opening ${label}.`;
673 }
674 return `Open ${label} for ${name}`;
675 },
676
677 async openExtensionUi(extension) {
678 const url = this.extensionOpenUrl(extension);
679 if (!url) return;
680 this.extensionActionMessage = "";
681 this.extensionActionError = "";
682 if (!extension?.enabled) {
683 this.extensionActionError = `Enable ${extension?.name || "this extension"} before opening it.`;
684 return;
685 }
686 this.closeExtensionsMenu();
687 await this.command("open", { url });
688 },
689
690 _prefillAgentPrompt(prompt) {
691 chatInputStore.message = prompt;
692 chatInputStore.adjustTextareaHeight?.();
693 chatInputStore.focus?.();
694 this.closeExtensionsMenu();
695 },
696
697 async onOpen(element = null, options = {}) {
698 const requestedBrowserId = this.normalizeBrowserId(
699 options.requestedBrowserId ?? options.browserId ?? options.browser_id,
700 );
701 const requestedContextId = this.normalizeContextId(
702 options.requestedContextId ?? options.contextId ?? options.context_id,
703 );
704 const nextMode = options?.mode === "modal" ? "modal" : "canvas";
705 if (nextMode === "canvas" && !this.isCanvasSurfaceVisible(element)) {
706 return;
707 }
708 const openSignature = this.surfaceOpenSignature(element, nextMode, requestedBrowserId, requestedContextId);
709 if (this._openPromise && this._openSignature === openSignature) {
710 return await this._openPromise;
711 }
712 const promise = this.openSurface(element, {
713 ...options,
714 requestedBrowserId,
715 requestedContextId,
716 nextMode,
717 });
718 this._openPromise = promise;
719 this._openSignature = openSignature;
720 try {
721 return await promise;
722 } finally {
723 if (this._openPromise === promise) {
724 this._openPromise = null;
725 this._openSignature = "";
726 }
727 }
728 },
729
730 async openSurface(element = null, options = {}) {
731 this.loading = true;
732 this.error = "";
733 const requestedBrowserId = this.normalizeBrowserId(
734 options.requestedBrowserId ?? options.browserId ?? options.browser_id,
735 );
736 const requestedContextId = this.normalizeContextId(
737 options.requestedContextId ?? options.contextId ?? options.context_id,
738 );
739 let targetContextId = requestedContextId
740 || this.contextIdForBrowserId(requestedBrowserId)
741 || this.resolveContextId();
742 const nextMode = options?.nextMode || (options?.mode === "modal" ? "modal" : "canvas");
743 if (nextMode === "canvas" && !this.isCanvasSurfaceVisible(element)) {
744 this.loading = false;
745 return;
746 }
747 const surfaceSequence = this._surfaceOpenSequence + 1;
748 this._surfaceOpenSequence = surfaceSequence;
749 this.prepareSurfaceOpen(nextMode, requestedBrowserId, requestedContextId);
750 if (nextMode === "modal") {
751 this.setupFloatingModal(element);
752 } else {
753 this.setupCanvasSurface(element);
754 }
755 try {
756 if (!targetContextId && !this.activeBrowserContextId && !this.contextId) {
757 targetContextId = await this.ensureContextId();
758 }
759 if (!this.isCurrentSurfaceOpen(surfaceSequence)) return;
760 await this.refreshStatus();
761 if (!this.isCurrentSurfaceOpen(surfaceSequence)) return;
762 const viewport = await this.waitForSurfaceViewport({ sequence: surfaceSequence });
763 if (!this.isCurrentSurfaceOpen(surfaceSequence)) return;
764 if (nextMode === "canvas" && !viewport) return;
765 this.resetRenderedFrameIfViewportChanged(viewport, requestedBrowserId, targetContextId);
766 await this.connectViewer({
767 browserId: requestedBrowserId,
768 contextId: targetContextId,
769 initialViewport: viewport,
770 });
771 if (!this.isCurrentSurfaceOpen(surfaceSequence)) return;
772 await this.syncViewportAfterSurfaceOpen(surfaceSequence);
773 } catch (error) {
774 if (this.isCurrentSurfaceOpen(surfaceSequence)) {
775 this.error = error instanceof Error ? error.message : String(error);
776 }
777 } finally {
778 if (this.isCurrentSurfaceOpen(surfaceSequence)) {
779 this.loading = false;
780 }
781 }
782 },
783
784 surfaceOpenSignature(element = null, mode = "", browserId = null, contextId = "") {
785 const root = element || globalThis.document?.querySelector(".browser-panel");
786 if (root && !root.__browserSurfaceOpenId) {
787 root.__browserSurfaceOpenId = makeViewerToken();
788 }
789 return [
790 mode || "",
791 this.normalizeContextId(contextId) || "",
792 this.normalizeBrowserId(browserId) || "",
793 root?.__browserSurfaceOpenId || "",
794 ].join(":");
795 },
796
797 isCurrentSurfaceOpen(sequence) {
798 return this._surfaceMounted && sequence === this._surfaceOpenSequence;
799 },
800
801 beginSurfaceHandoff() {
802 if (this._surfaceHandoffTimer) {
803 globalThis.clearTimeout(this._surfaceHandoffTimer);
804 }
805 this._surfaceHandoff = true;
806 this._surfaceHandoffTimer = globalThis.setTimeout(() => {
807 this._surfaceHandoff = false;
808 this._surfaceHandoffTimer = null;
809 }, 3000);
810 },
811
812 finishSurfaceHandoff() {
813 if (this._surfaceHandoffTimer) {
814 globalThis.clearTimeout(this._surfaceHandoffTimer);
815 this._surfaceHandoffTimer = null;
816 }
817 this._surfaceHandoff = false;
818 },
819
820 cancelSurfaceHandoff() {
821 if (this._surfaceHandoffTimer) {
822 globalThis.clearTimeout(this._surfaceHandoffTimer);
823 this._surfaceHandoffTimer = null;
824 }
825 this._surfaceHandoff = false;
826 },
827
828 releaseSurfaceBindings() {
829 this.freezeCanvasFrameToImage();
830 this._floatingCleanup?.();
831 this._floatingCleanup = null;
832 this._stageResizeObserver?.disconnect?.();
833 this._stageResizeObserver = null;
834 this._stageElement = null;
835 this._frameCanvas = null;
836 },
837
838 isCanvasSurfaceVisible(element = null) {
839 const root = element
840 || globalThis.document?.querySelector?.(".browser-canvas-surface .browser-panel")
841 || globalThis.document?.querySelector?.(".browser-panel");
842 if (!root?.isConnected) return false;
843 const surface = root.closest?.(".browser-canvas-surface");
844 const stage = root.querySelector?.(".browser-stage") || root;
845 const surfaceStyle = surface ? globalThis.getComputedStyle?.(surface) : null;
846 const rootStyle = globalThis.getComputedStyle?.(root);
847 if (surfaceStyle?.display === "none" || surfaceStyle?.visibility === "hidden") return false;
848 if (rootStyle?.display === "none" || rootStyle?.visibility === "hidden") return false;
849 const rect = stage.getBoundingClientRect?.();
850 return Boolean(rect && Math.round(rect.width || 0) >= 80 && Math.round(rect.height || 0) >= 80);
851 },
852
853 isVisibleBrowserSurface() {
854 if (!this._surfaceMounted) return false;
855 if (this._mode === "canvas") {
856 return Boolean(rightCanvasStore?.isSurfaceVisible?.("browser"))
857 && this.isCanvasSurfaceVisible(globalThis.document?.querySelector?.(".browser-canvas-surface .browser-panel"));
858 }
859
860 const panel = globalThis.document?.querySelector?.(".modal .browser-panel");
861 const modal = panel?.closest?.(".modal");
862 if (!panel || !modal) return false;
863 if (modal.classList.contains("modal-surface-parked") || modal.classList.contains("surface-modal-parked")) {
864 return false;
865 }
866 const panelStyle = globalThis.getComputedStyle?.(panel);
867 if (panelStyle?.display === "none" || panelStyle?.visibility === "hidden") return false;
868 const rect = panel.getBoundingClientRect?.();
869 return Boolean(rect && Math.round(rect.width || 0) >= 80 && Math.round(rect.height || 0) >= 80);
870 },
871
872 prepareSurfaceOpen(nextMode, requestedBrowserId = null, requestedContextId = "") {
873 const targetBrowserId = requestedBrowserId || this.activeBrowserId || this.firstBrowserId(requestedContextId);
874 const targetContextId = this.normalizeContextId(
875 requestedContextId
876 || this.contextIdForBrowserId(targetBrowserId)
877 || this.resolveContextId()
878 || this.activeBrowserContextId
879 || this.contextId,
880 );
881 const targetChanged = Boolean(
882 targetBrowserId
883 && this.activeBrowserId
884 && !this.sameBrowserTab(targetBrowserId, targetContextId, this.activeBrowserId, this.activeBrowserContextId),
885 );
886 this._mode = nextMode;
887 this._surfaceMounted = true;
888 this._surfaceOpenedAt = Date.now();
889 this._lastViewportKey = "";
890 if (this.hasFrame() && !targetChanged) {
891 this._surfaceSwitching = false;
892 this.switchingBrowserId = null;
893 return;
894 }
895 if (!targetBrowserId) return;
896
897 this.resetRenderedFrame();
898 this.resetViewportTracking();
899 this._surfaceSwitching = Boolean(targetBrowserId);
900 this.switchingBrowserId = targetBrowserId;
901 },
902
903 resetViewportTracking() {
904 this._lastViewportKey = "";
905 this._lastViewport = null;
906 },
907
908 resetRenderedFrame() {
909 this.cancelFrameRender();
910 this.interactiveViewUrl = "";
911 this.clearFrameSrc();
912 this.clearFrameCanvas();
913 this._lastFrameDimensions = null;
914 this._lastFrameAt = 0;
915 },
916
917 resetRenderedFrameIfViewportChanged(viewport = null, requestedBrowserId = null, requestedContextId = "") {
918 if (!viewport || !this.hasFrame() || !this._lastViewport) return;
919 const targetBrowserId = requestedBrowserId || this.activeBrowserId || this.firstBrowserId();
920 const targetContextId = this.normalizeContextId(requestedContextId || this.contextIdForBrowserId(targetBrowserId) || this.activeBrowserContextId);
921 if (!this.sameBrowserTab(this._lastViewport.browserId, this._lastViewport.contextId, targetBrowserId, targetContextId)) return;
922 const changed = Math.abs(this._lastViewport.width - viewport.width) > VIEWPORT_SYNC_SIZE_TOLERANCE
923 || Math.abs(this._lastViewport.height - viewport.height) > VIEWPORT_SYNC_SIZE_TOLERANCE;
924 if (!changed) return;
925
926 this.cancelFrameRender();
927 this.resetViewportTracking();
928 this._surfaceSwitching = true;
929 this.switchingBrowserId = targetBrowserId;
930 },
931
932 async waitForSurfaceViewport(options = {}) {
933 const sequence = Number(options.sequence || 0);
934 const startedAt = Date.now();
935 let lastKey = "";
936 let stableCount = 0;
937 while (Date.now() - startedAt <= SURFACE_VIEWPORT_MAX_WAIT_MS) {
938 await nextAnimationFrame();
939 if (sequence && !this.isCurrentSurfaceOpen(sequence)) {
940 return null;
941 }
942 const viewport = this.surfaceViewportMeasurement();
943 if (!viewport) continue;
944 const key = `${viewport.rawWidth}x${viewport.rawHeight}`;
945 if (key === lastKey) {
946 stableCount += 1;
947 const canvasSettled = this._mode !== "canvas"
948 || !this._surfaceOpenedAt
949 || Date.now() - this._surfaceOpenedAt >= CANVAS_VIEWPORT_SETTLE_MS;
950 if (canvasSettled && stableCount >= SURFACE_VIEWPORT_STABLE_FRAMES) {
951 return { width: viewport.width, height: viewport.height };
952 }
953 } else {
954 stableCount = 0;
955 lastKey = key;
956 }
957 }
958 const fallbackViewport = this.currentViewportSize();
959 return fallbackViewport;
960 },
961
962 async syncViewportAfterSurfaceOpen(sequence = this._surfaceOpenSequence) {
963 if (!this.connected || !this.activeBrowserId) return;
964 const surfaceMode = this._mode;
965 await this.waitForSurfaceViewport({ sequence });
966 if (!this.isCurrentSurfaceOpen(sequence)) {
967 return;
968 }
969 await this.syncViewport(true, {
970 restartStream: this._mode === "canvas" && this.usesScreencastTransport(),
971 });
972 if (surfaceMode === "modal" && this.usesInteractiveTransport()) {
973 this.scheduleViewportSyncForSurface(sequence, INTERACTIVE_VIEWPORT_SETTLE_MS, surfaceMode);
974 return;
975 }
976 if (surfaceMode !== "canvas") return;
977 this.scheduleViewportSyncForSurface(sequence, 240, surfaceMode);
978 this.scheduleViewportSyncForSurface(sequence, 520, surfaceMode);
979 },
980
981 requestedViewerTransport() {
982 return BROWSER_VIEWER_TRANSPORT_INTERACTIVE;
983 },
984
985 normalizeViewerTransport(value = "") {
986 const normalized = String(value || "").trim().toLowerCase().replace("-", "_");
987 if (normalized === BROWSER_VIEWER_TRANSPORT_INTERACTIVE) {
988 return BROWSER_VIEWER_TRANSPORT_INTERACTIVE;
989 }
990 if (normalized === BROWSER_VIEWER_TRANSPORT_SCREENCAST) {
991 return BROWSER_VIEWER_TRANSPORT_SCREENCAST;
992 }
993 return BROWSER_VIEWER_TRANSPORT_SNAPSHOT;
994 },
995
996 normalizeTabScope(value = "") {
997 return String(value || "").trim().toLowerCase().replace("-", "_") === "shared"
998 ? "shared"
999 : "per_context";
1000 },
1001
1002 applyTabScope(data = {}) {
1003 if (!data || typeof data !== "object") return;
1004 if (!Object.prototype.hasOwnProperty.call(data, "tab_scope")) return;
1005 this.tabScope = this.normalizeTabScope(data.tab_scope);
1006 },
1007
1008 usesScreencastTransport() {
1009 return this.viewerTransport === BROWSER_VIEWER_TRANSPORT_SCREENCAST;
1010 },
1011
1012 usesInteractiveTransport() {
1013 return this.viewerTransport === BROWSER_VIEWER_TRANSPORT_INTERACTIVE
1014 && Boolean(this.interactiveViewUrl);
1015 },
1016
1017 isInteractiveSurface(stage = null) {
1018 return this.usesInteractiveTransport() && stage === this._stageElement;
1019 },
1020
1021 prepareInteractiveViewFrame(frame = null) {
1022 const target = frame || this._stageElement?.querySelector?.(".browser-interactive-frame");
1023 const remoteWindow = target?.contentWindow;
1024 if (!remoteWindow) return false;
1025 try {
1026 const remoteDocument = target.contentDocument || remoteWindow.document;
1027 if (!remoteDocument) return false;
1028 if (!remoteDocument.getElementById("a0-xpra-browser-frame-css")) {
1029 const style = remoteDocument.createElement("style");
1030 style.id = "a0-xpra-browser-frame-css";
1031 style.textContent = `
1032 #shadow_pointer {
1033 display: none !important;
1034 visibility: hidden !important;
1035 opacity: 0 !important;
1036 }
1037 .window canvas,
1038 .undecorated canvas {
1039 display: block !important;
1040 margin: 0 !important;
1041 }
1042 `;
1043 remoteDocument.head?.appendChild(style);
1044 }
1045
1046 const normalizeWindows = () => {
1047 const windows = Object.values(remoteWindow.client?.id_to_window || {});
1048 for (const xpraWindow of windows) {
1049 xpraWindow.resizable = false;
1050 xpraWindow.decorations = false;
1051 xpraWindow.decorated = false;
1052 xpraWindow.metadata = { ...(xpraWindow.metadata || {}), decorations: false };
1053 xpraWindow._set_decorated?.(false);
1054 xpraWindow.configure_border_class?.();
1055 xpraWindow.leftoffset = 0;
1056 xpraWindow.rightoffset = 0;
1057 xpraWindow.topoffset = 0;
1058 xpraWindow.bottomoffset = 0;
1059 xpraWindow.updateCSSGeometry?.();
1060 }
1061 return windows.length > 0;
1062 };
1063
1064 const screen = remoteDocument.querySelector?.("#screen");
1065 if (screen && !remoteWindow.__a0BrowserFrameObserver && remoteWindow.MutationObserver) {
1066 const observer = new remoteWindow.MutationObserver(normalizeWindows);
1067 observer.observe(screen, { childList: true });
1068 remoteWindow.__a0BrowserFrameObserver = observer;
1069 }
1070 return normalizeWindows();
1071 } catch {
1072 return false;
1073 }
1074 },
1075
1076 syncInteractiveViewSize() {
1077 if (!this.usesInteractiveTransport()) return;
1078 const frame = this._stageElement?.querySelector?.(".browser-interactive-frame");
1079 try {
1080 this.prepareInteractiveViewFrame(frame);
1081 frame?.contentWindow?.client?._screen_resized?.();
1082 } catch {}
1083 },
1084
1085 applyViewer(data = {}) {
1086 if (data?.viewer_transport) {
1087 this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
1088 }
1089 if (Object.prototype.hasOwnProperty.call(data || {}, "interactive_view")) {
1090 const viewer = data.interactive_view;
1091 this.interactiveViewUrl = viewer?.available && viewer?.url ? String(viewer.url) : "";
1092 this.viewerFallbackReason = String(data.viewer_fallback_reason || viewer?.error || "");
1093 }
1094 if (this.viewerTransport !== BROWSER_VIEWER_TRANSPORT_INTERACTIVE) {
1095 this.interactiveViewUrl = "";
1096 }
1097 },
1098
1099 onInteractiveViewLoad() {
1100 if (!this.usesInteractiveTransport()) return;
1101 this.prepareInteractiveViewFrame();
1102 this.switchingBrowserId = null;
1103 this._surfaceSwitching = false;
1104 this.queueViewportSync(true);
1105 },
1106
1107 supportsBinaryFrames() {
1108 return BROWSER_BINARY_FRAME_REQUESTS_ENABLED && BROWSER_BINARY_PAYLOADS_SUPPORTED;
1109 },
1110
1111 captureDevicePixelRatio() {
1112 const value = Number(globalThis.devicePixelRatio || 1);
1113 if (!Number.isFinite(value) || value <= 1) return 1;
1114 return Math.min(2, value);
1115 },
1116
1117 frameDimensionsFromData(data = null) {
1118 const width = Number(data?.width || 0);
1119 const height = Number(data?.height || 0);
1120 if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
1121 return { width, height };
1122 }
1123 return this.frameDimensionsFromMetadata(data?.metadata);
1124 },
1125
1126 frameDimensionsFromMetadata(metadata = null) {
1127 if (!metadata || typeof metadata !== "object") return null;
1128 const width = Number(metadata.expectedWidth || metadata.deviceWidth || metadata.jpegWidth || 0);
1129 const height = Number(metadata.expectedHeight || metadata.deviceHeight || metadata.jpegHeight || 0);
1130 if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
1131 return null;
1132 }
1133 return { width, height };
1134 },
1135
1136 scheduleViewportSyncForSurface(sequence, delayMs = 0, mode = this._mode) {
1137 globalThis.setTimeout?.(() => {
1138 if (!this.isCurrentSurfaceOpen(sequence) || this._mode !== mode) {
1139 return;
1140 }
1141 this.queueViewportSync(true);
1142 }, delayMs);
1143 },
1144
1145 async connectViewer(options = {}) {
1146 let contextId = "";
1147 const requestedBrowserId = this.normalizeBrowserId(options.browserId ?? this.activeBrowserId);
1148 const requestedContextId = this.normalizeContextId(
1149 options.contextId
1150 ?? options.context_id
1151 ?? this.contextIdForBrowserId(requestedBrowserId)
1152 ?? this.activeBrowserContextId
1153 ?? this.contextId
1154 );
1155 try {
1156 contextId = requestedContextId || await this.ensureContextId();
1157 } catch (error) {
1158 this.connected = false;
1159 this.switchingBrowserId = null;
1160 this._surfaceSwitching = false;
1161 throw error;
1162 }
1163 if (!contextId) {
1164 this.connected = false;
1165 this.error = "Could not create a chat for Browser.";
1166 this.switchingBrowserId = null;
1167 this._surfaceSwitching = false;
1168 return;
1169 }
1170 const previousContextId = this.normalizeContextId(this.contextId);
1171 if (previousContextId && previousContextId !== contextId) {
1172 try {
1173 await websocket.emit("browser_viewer_unsubscribe", { context_id: previousContextId });
1174 } catch {}
1175 }
1176 this.contextId = contextId;
1177 const sequence = this._connectSequence + 1;
1178 const viewerToken = makeViewerToken();
1179 this._connectSequence = sequence;
1180 this._viewerToken = viewerToken;
1181 this.error = "";
1182 await this._bindSocketEvents();
1183 if (sequence !== this._connectSequence || viewerToken !== this._viewerToken) {
1184 return;
1185 }
1186 const initialViewport = options.initialViewport || this.currentViewportSize();
1187 let response;
1188 try {
1189 response = await websocket.request(
1190 "browser_viewer_subscribe",
1191 {
1192 context_id: contextId,
1193 browser_id: requestedBrowserId,
1194 viewer_id: viewerToken,
1195 create_browser: Boolean(options.createBrowser || options.create_browser),
1196 viewer_transport: this.requestedViewerTransport(),
1197 binary_frames: this.supportsBinaryFrames(),
1198 slim_frames: true,
1199 device_pixel_ratio: this.captureDevicePixelRatio(),
1200 viewport_width: initialViewport?.width,
1201 viewport_height: initialViewport?.height,
1202 },
1203 {
1204 timeoutMs: this.browserInstallExpected
1205 ? BROWSER_FIRST_INSTALL_TIMEOUT_MS
1206 : BROWSER_SUBSCRIBE_TIMEOUT_MS,
1207 },
1208 );
1209 } catch (error) {
1210 if (sequence === this._connectSequence && viewerToken === this._viewerToken) {
1211 this.switchingBrowserId = null;
1212 this._surfaceSwitching = false;
1213 throw error;
1214 }
1215 return;
1216 }
1217 if (sequence !== this._connectSequence || viewerToken !== this._viewerToken) {
1218 return;
1219 }
1220 const data = firstOk(response);
1221 this.applyTabScope(data);
1222 this.applyBrowserListing(data.browsers || [], contextId, {
1223 replaceAll: Boolean(data.all_browsers),
1224 replaceContext: !data.all_browsers,
1225 });
1226 this.applyViewer(data);
1227 this._subscribedViewerTransport = this.viewerTransport;
1228 this.setActiveBrowserId(
1229 data.active_browser_id || requestedBrowserId || this.activeBrowserId || null,
1230 data.active_browser_context_id || contextId,
1231 );
1232 this.applySnapshot(data.snapshot);
1233 this.connected = true;
1234 this.browserInstallExpected = false;
1235 },
1236
1237 async _bindSocketEvents() {
1238 if (!this._frameOff) {
1239 const frameHandler = ({ data }) => {
1240 if (data?.context_id !== this.contextId) return;
1241 if (data?.viewer_id && data.viewer_id !== this._viewerToken) return;
1242 this.applyViewer(data);
1243 this.applyTabScope(data);
1244 const incomingContextId = this.normalizeContextId(data.context_id || this.contextId);
1245 const incomingBrowserId = this.normalizeBrowserId(data.browser_id || data.state?.id);
1246 if (Array.isArray(data.browsers)) {
1247 this.applyBrowserListing(data.browsers, incomingContextId, {
1248 replaceAll: Boolean(data.all_browsers),
1249 replaceContext: !data.all_browsers,
1250 });
1251 }
1252 if (incomingBrowserId && !this.activeBrowserId) {
1253 this.setActiveBrowserId(incomingBrowserId, incomingContextId);
1254 }
1255 if (
1256 incomingBrowserId
1257 && this.activeBrowserId
1258 && !this.sameBrowserTab(incomingBrowserId, incomingContextId, this.activeBrowserId, this.activeBrowserContextId)
1259 ) {
1260 return;
1261 }
1262 if (data.state) {
1263 this.frameState = data.state;
1264 }
1265 if (!this.addressFocused && data.state?.currentUrl) {
1266 this.address = data.state.currentUrl;
1267 }
1268 if (data.image) {
1269 const frameImage = frameImageSource(data);
1270 if (!frameImage?.src) return;
1271 const frameBrowserId = incomingBrowserId || this.activeBrowserId;
1272 this.queueFrameRender(frameImage.src, {
1273 browserId: frameBrowserId,
1274 contextId: incomingContextId,
1275 dimensions: this.frameDimensionsFromData(data),
1276 blob: frameImage.blob,
1277 objectUrl: frameImage.objectUrl,
1278 useCanvas: true,
1279 cleanup: frameImage.cleanup,
1280 onAccepted: () => {
1281 if (
1282 this.sameBrowserId(this.switchingBrowserId, frameBrowserId)
1283 && this.normalizeContextId(this.activeBrowserContextId) === incomingContextId
1284 ) {
1285 this.switchingBrowserId = null;
1286 }
1287 this._surfaceSwitching = false;
1288 },
1289 });
1290 } else if (!data.state) {
1291 this.cancelFrameRender();
1292 this.clearFrameSrc();
1293 this.clearFrameCanvas();
1294 }
1295 if (!data.image && !data.state) {
1296 if (!this.activeBrowserId) {
1297 this.setActiveBrowserId(null, "");
1298 this.frameState = null;
1299 this.clearFrameSrc();
1300 this.clearFrameCanvas();
1301 }
1302 }
1303 this._lastFrameAt = Date.now();
1304 };
1305 await websocket.on("browser_viewer_frame", frameHandler);
1306 this._frameOff = () => websocket.off("browser_viewer_frame", frameHandler);
1307 }
1308 if (!this._stateOff) {
1309 const stateHandler = ({ data }) => {
1310 if (data?.context_id !== this.contextId) return;
1311 if (data?.viewer_id && data.viewer_id !== this._viewerToken) return;
1312 this.applyViewer(data);
1313 this.applyTabScope(data);
1314 const commandContextId = this.normalizeContextId(data.active_browser_context_id || data.context_id || this.contextId);
1315 if (Array.isArray(data.browsers)) {
1316 this.applyBrowserListing(data.browsers, commandContextId, {
1317 replaceAll: Boolean(data.all_browsers),
1318 replaceContext: !data.all_browsers,
1319 });
1320 }
1321 const command = String(data.command || "").toLowerCase();
1322 const commandBrowserId = this.normalizeBrowserId(data.browser_id);
1323 const result = data.result || {};
1324 const resultState = this.stateFromCommandResult(result);
1325 const resultContextId = this.normalizeContextId(
1326 result.context_id
1327 || result.state?.context_id
1328 || commandContextId
1329 );
1330 const preferredBrowserId = this.normalizeBrowserId(
1331 result.id
1332 || result.state?.id
1333 || data.last_interacted_browser_id
1334 || this.activeBrowserId
1335 || this.firstBrowserId(resultContextId)
1336 );
1337 const stateBrowserId = this.normalizeBrowserId(data.active_browser_id || data.browser_id || data.state?.id);
1338 if (
1339 stateBrowserId
1340 && (
1341 !this.activeBrowserId
1342 || this.sameBrowserTab(stateBrowserId, commandContextId, this.activeBrowserId, this.activeBrowserContextId)
1343 )
1344 ) {
1345 this.setActiveBrowserId(stateBrowserId, commandContextId);
1346 }
1347 if (
1348 !this.activeBrowserId
1349 || command === "open"
1350 || command === "close"
1351 || this.sameBrowserTab(commandBrowserId, commandContextId, this.activeBrowserId, this.activeBrowserContextId)
1352 ) {
1353 this.setActiveBrowserId(preferredBrowserId, resultContextId);
1354 }
1355 this.applyActiveFrameState(
1356 resultState
1357 || data.state
1358 || this.browserById(this.activeBrowserId, this.activeBrowserContextId)
1359 );
1360 this.applySnapshot(data.snapshot);
1361 };
1362 await websocket.on("browser_viewer_state", stateHandler);
1363 this._stateOff = () => websocket.off("browser_viewer_state", stateHandler);
1364 }
1365 },
1366
1367 queueFrameRender(frameSrc, options = {}) {
1368 if (this._pendingFrameSrc) {
1369 this.releasePendingFrame();
1370 }
1371 this._pendingFrameSrc = frameSrc;
1372 this._pendingFrameOptions = options || null;
1373 if (this._frameRenderHandle) return;
1374 const schedule = globalThis.requestAnimationFrame?.bind(globalThis);
1375 if (schedule) {
1376 this._frameRenderCancel = globalThis.cancelAnimationFrame?.bind(globalThis) || null;
1377 this._frameRenderHandle = schedule(() => this.flushFrameRender());
1378 return;
1379 }
1380 this._frameRenderCancel = globalThis.clearTimeout?.bind(globalThis) || null;
1381 this._frameRenderHandle = globalThis.setTimeout(() => this.flushFrameRender(), 16);
1382 },
1383
1384 flushFrameRender() {
1385 this._frameRenderHandle = null;
1386 this._frameRenderCancel = null;
1387 const frameSrc = this._pendingFrameSrc || "";
1388 const options = this._pendingFrameOptions || {};
1389 this._pendingFrameSrc = "";
1390 this._pendingFrameOptions = null;
1391 const sequence = this._frameRenderSequence + 1;
1392 const surfaceSequence = this._surfaceOpenSequence;
1393 this._frameRenderSequence = sequence;
1394 void this.renderDecodedFrame(frameSrc, options, sequence, surfaceSequence);
1395 },
1396
1397 async renderDecodedFrame(frameSrc, options = {}, sequence = 0, surfaceSequence = this._surfaceOpenSequence) {
1398 if (!frameSrc) {
1399 if (sequence === this._frameRenderSequence) {
1400 this.clearFrameSrc();
1401 this.clearFrameCanvas();
1402 }
1403 return;
1404 }
1405 let bitmap = null;
1406 let dimensions = options?.dimensions || null;
1407 if (options?.useCanvas && this.canUseCanvasFrames()) {
1408 bitmap = await loadFrameBitmap(frameSrc, options);
1409 if (bitmap) {
1410 dimensions ||= { width: bitmap.width || 0, height: bitmap.height || 0 };
1411 }
1412 }
1413 dimensions ||= await loadFrameDimensions(frameSrc);
1414 if (sequence !== this._frameRenderSequence || surfaceSequence !== this._surfaceOpenSequence) {
1415 bitmap?.close?.();
1416 options?.cleanup?.();
1417 return;
1418 }
1419 const viewport = this.currentViewportSize() || this._lastViewport;
1420 if (!this.frameMatchesViewport(dimensions, viewport)) {
1421 this.requestViewportSyncAfterRejectedFrame();
1422 bitmap?.close?.();
1423 options?.cleanup?.();
1424 return;
1425 }
1426 if (bitmap && this.paintFrameBitmap(bitmap)) {
1427 this.clearFrameSrc();
1428 options?.cleanup?.();
1429 } else {
1430 this.clearFrameCanvas();
1431 this.releaseRenderedFrameUrl(frameSrc);
1432 this.frameSrc = frameSrc;
1433 this._frameObjectUrl = options?.objectUrl || "";
1434 }
1435 bitmap?.close?.();
1436 this._lastFrameDimensions = dimensions;
1437 this._lastFrameAt = Date.now();
1438 options?.onAccepted?.();
1439 },
1440
1441 frameMatchesViewport(dimensions = null, viewport = null) {
1442 if (!dimensions?.width || !dimensions?.height || !viewport?.width || !viewport?.height) {
1443 return false;
1444 }
1445 return Math.abs(Number(dimensions.width) - Number(viewport.width)) <= VIEWPORT_SYNC_SIZE_TOLERANCE
1446 && Math.abs(Number(dimensions.height) - Number(viewport.height)) <= VIEWPORT_SYNC_SIZE_TOLERANCE;
1447 },
1448
1449 requestViewportSyncAfterRejectedFrame() {
1450 const now = Date.now();
1451 if (now - this._lastFrameRejectSyncAt < FRAME_REJECT_SYNC_COOLDOWN_MS) {
1452 return;
1453 }
1454 this._lastFrameRejectSyncAt = now;
1455 this.queueViewportSync(true);
1456 },
1457
1458 clearRenderedFrameIfViewportChanged() {
1459 const viewport = this.currentViewportSize();
1460 if (!this.hasFrame() || !this._lastFrameDimensions || !viewport) return;
1461 if (this.frameMatchesViewport(this._lastFrameDimensions, viewport)) return;
1462 this.cancelFrameRender();
1463 this.resetViewportTracking();
1464 if (this.activeBrowserId) {
1465 this._surfaceSwitching = true;
1466 this.switchingBrowserId = this.activeBrowserId;
1467 }
1468 },
1469
1470 cancelFrameRender() {
1471 if (this._frameRenderHandle && this._frameRenderCancel) {
1472 this._frameRenderCancel(this._frameRenderHandle);
1473 }
1474 this._frameRenderHandle = null;
1475 this._frameRenderCancel = null;
1476 this.releasePendingFrame();
1477 this._frameRenderSequence += 1;
1478 },
1479
1480 releasePendingFrame() {
1481 this._pendingFrameOptions?.cleanup?.();
1482 this._pendingFrameSrc = "";
1483 this._pendingFrameOptions = null;
1484 },
1485
1486 releaseRenderedFrameUrl(nextSrc = "") {
1487 if (this._frameObjectUrl && this._frameObjectUrl !== nextSrc) {
1488 globalThis.URL?.revokeObjectURL?.(this._frameObjectUrl);
1489 this._frameObjectUrl = "";
1490 }
1491 },
1492
1493 clearFrameSrc() {
1494 this.releaseRenderedFrameUrl("");
1495 this.frameSrc = "";
1496 },
1497
1498 attachFrameCanvas(canvas = null) {
1499 this._frameCanvas = canvas || null;
1500 },
1501
1502 currentFrameCanvas() {
1503 const stageCanvas = this._stageElement?.querySelector?.(".browser-frame-canvas");
1504 if (stageCanvas?.isConnected) return stageCanvas;
1505 if (this._frameCanvas?.isConnected) return this._frameCanvas;
1506 return null;
1507 },
1508
1509 canUseCanvasFrames() {
1510 return Boolean(BROWSER_CANVAS_FRAMES_SUPPORTED && this.currentFrameCanvas()?.getContext);
1511 },
1512
1513 hasFrame() {
1514 return Boolean(this.interactiveViewUrl || this.frameSrc || this.frameCanvasReady);
1515 },
1516
1517 paintFrameBitmap(bitmap) {
1518 const canvas = this.currentFrameCanvas();
1519 if (!canvas || !bitmap?.width || !bitmap?.height) return false;
1520 if (canvas.width !== bitmap.width) canvas.width = bitmap.width;
1521 if (canvas.height !== bitmap.height) canvas.height = bitmap.height;
1522 const context = canvas.getContext("2d");
1523 if (!context) return false;
1524 context.drawImage(bitmap, 0, 0);
1525 this.frameCanvasReady = true;
1526 return true;
1527 },
1528
1529 clearFrameCanvas() {
1530 const canvas = this.currentFrameCanvas();
1531 if (canvas?.width && canvas?.height) {
1532 canvas.getContext("2d")?.clearRect(0, 0, canvas.width, canvas.height);
1533 }
1534 this.frameCanvasReady = false;
1535 },
1536
1537 freezeCanvasFrameToImage() {
1538 const canvas = this.currentFrameCanvas();
1539 if (!this.frameCanvasReady || !canvas) return;
1540 try {
1541 this.frameSrc = canvas.toDataURL("image/jpeg", 0.86);
1542 } catch {
1543 this.frameSrc = "";
1544 }
1545 this.clearFrameCanvas();
1546 },
1547
1548 frameElement() {
1549 if (this.usesInteractiveTransport()) {
1550 const iframe = this._stageElement?.querySelector?.(".browser-interactive-frame");
1551 if (iframe) return iframe;
1552 }
1553 if (this.frameCanvasReady) {
1554 const canvas = this.currentFrameCanvas();
1555 if (canvas) return canvas;
1556 }
1557 return this._stageElement?.querySelector?.(".browser-frame-image") || null;
1558 },
1559
1560 beginCommand() {
1561 this._commandInFlightCount += 1;
1562 this.commandInFlight = true;
1563 },
1564
1565 finishCommand() {
1566 this._commandInFlightCount = Math.max(0, this._commandInFlightCount - 1);
1567 this.commandInFlight = this._commandInFlightCount > 0;
1568 },
1569
1570 async command(command, extra = {}) {
1571 this.error = "";
1572 this.annotationError = "";
1573 this.beginCommand();
1574 const previousActiveBrowserId = this.activeBrowserId;
1575 const previousActiveContextId = this.activeBrowserContextId;
1576 const commandName = String(command || "").toLowerCase();
1577 try {
1578 const targetContextId = commandName === "open"
1579 ? await this.contextIdForNewBrowser()
1580 : this.normalizeContextId(extra.context_id || extra.contextId) || await this.contextIdForActiveBrowser();
1581 const targetBrowserId = this.normalizeBrowserId(extra.browser_id ?? this.activeBrowserId);
1582 this.contextId = targetContextId;
1583 const response = await websocket.request(
1584 "browser_viewer_command",
1585 {
1586 ...extra,
1587 context_id: targetContextId,
1588 browser_id: targetBrowserId,
1589 viewer_id: this._viewerToken,
1590 viewer_transport: this.requestedViewerTransport(),
1591 command,
1592 },
1593 { timeoutMs: BROWSER_COMMAND_TIMEOUT_MS },
1594 );
1595 const data = firstOk(response);
1596 this.applyTabScope(data);
1597 this.applyBrowserListing(data.browsers || [], targetContextId, {
1598 replaceAll: Boolean(data.all_browsers),
1599 replaceContext: !data.all_browsers,
1600 });
1601 this.applyViewer(data);
1602 const result = data.result || {};
1603 const resultContextId = this.normalizeContextId(
1604 result.context_id
1605 || result.state?.context_id
1606 || data.active_browser_context_id
1607 || targetContextId
1608 );
1609 const preferredBrowser = this.browserById(
1610 result.id
1611 || result.state?.id
1612 || result.last_interacted_browser_id
1613 || data.last_interacted_browser_id,
1614 resultContextId,
1615 )
1616 || this.browserById(this.activeBrowserId, this.activeBrowserContextId)
1617 || this.firstBrowser(resultContextId)
1618 || this.firstBrowser();
1619 this.setActiveBrowserId(preferredBrowser?.id || null, preferredBrowser?.context_id || resultContextId);
1620 this.applyActiveFrameState(
1621 this.stateFromCommandResult(result)
1622 || this.browserById(this.activeBrowserId, this.activeBrowserContextId)
1623 );
1624 if (!this.activeBrowserId) {
1625 this.frameState = null;
1626 this.clearFrameSrc();
1627 this.clearFrameCanvas();
1628 }
1629 if (result.state?.currentUrl || result.currentUrl) {
1630 this.address = result.state?.currentUrl || result.currentUrl;
1631 }
1632 this.applySnapshot(data.snapshot);
1633 if (["navigate", "back", "forward", "reload", "close"].includes(commandName)) {
1634 this.cancelAnnotationDraft();
1635 this.clearAnnotationHover();
1636 }
1637 const activeChanged = this.activeBrowserId
1638 && !this.sameBrowserTab(
1639 this.activeBrowserId,
1640 this.activeBrowserContextId,
1641 previousActiveBrowserId,
1642 previousActiveContextId,
1643 );
1644 const viewerTransportChanged = this._subscribedViewerTransport !== this.viewerTransport;
1645 if (
1646 (commandName === "open" || commandName === "close" || activeChanged || viewerTransportChanged)
1647 && this.contextId
1648 && this.activeBrowserId
1649 ) {
1650 await this.connectViewer({
1651 browserId: this.activeBrowserId,
1652 contextId: this.activeBrowserContextId,
1653 });
1654 } else if (["navigate", "back", "forward", "reload"].includes(commandName)) {
1655 await this.restartCanvasStreamAfterPageChange();
1656 }
1657 } catch (error) {
1658 this.error = error instanceof Error ? error.message : String(error);
1659 } finally {
1660 this.finishCommand();
1661 }
1662 },
1663
1664 async restartCanvasStreamAfterPageChange() {
1665 if (!this.usesScreencastTransport()) {
1666 return;
1667 }
1668 const surfaceSequence = this._surfaceOpenSequence;
1669 if (this._mode !== "canvas" || !this.isCurrentSurfaceOpen(surfaceSequence) || !this.activeBrowserId) {
1670 return;
1671 }
1672 await this.waitForSurfaceViewport({ sequence: surfaceSequence });
1673 if (this._mode !== "canvas" || !this.isCurrentSurfaceOpen(surfaceSequence) || !this.activeBrowserId) {
1674 return;
1675 }
1676 await this.syncViewport(true, { restartStream: true });
1677 },
1678
1679 async go() {
1680 const url = String(this.address || "").trim();
1681 if (!url) return;
1682 this.addressFocused = false;
1683 globalThis.document?.activeElement?.blur?.();
1684 if (this.activeBrowserId) {
1685 await this.command("navigate", { url });
1686 } else {
1687 await this.command("open", { url });
1688 }
1689 },
1690
1691 async openUrlIntent(url = "", options = {}) {
1692 if (this._openPromise) {
1693 try {
1694 await this._openPromise;
1695 } catch {}
1696 }
1697 if (!this._surfaceMounted) return false;
1698 const targetUrl = String(url || "").trim();
1699 if (targetUrl) {
1700 await this.command("open", {
1701 url: targetUrl,
1702 source: options?.source || "desktop-url",
1703 });
1704 return true;
1705 }
1706 if (!this.activeBrowserId) {
1707 await this.command("open");
1708 }
1709 return true;
1710 },
1711
1712 onAddressFocus() {
1713 this.addressFocused = true;
1714 },
1715
1716 onAddressBlur() {
1717 this.addressFocused = false;
1718 if (this.frameState?.currentUrl && !String(this.address || "").trim()) {
1719 this.address = this.frameState.currentUrl;
1720 }
1721 },
1722
1723 async selectBrowser(id, contextId = "") {
1724 const targetId = this.normalizeBrowserId(id);
1725 const targetContextId = this.normalizeContextId(contextId || this.contextIdForBrowserId(targetId));
1726 if (!targetId) {
1727 await this.openNewBrowser();
1728 return;
1729 }
1730 if (
1731 this.sameBrowserTab(targetId, targetContextId, this.activeBrowserId, this.activeBrowserContextId)
1732 && this.connected
1733 && !this.isSwitchingBrowser()
1734 ) {
1735 return;
1736 }
1737 const browser = this.browserById(targetId, targetContextId);
1738 this.error = "";
1739 this.switchingBrowserId = targetId;
1740 this.cancelFrameRender();
1741 this.clearFrameSrc();
1742 this.clearFrameCanvas();
1743 this.frameState = browser || null;
1744 if (!this.addressFocused && browser?.currentUrl) {
1745 this.address = browser.currentUrl;
1746 }
1747 this.setActiveBrowserId(targetId, targetContextId);
1748 if (this.activeBrowserContextId) {
1749 try {
1750 await this.connectViewer({ browserId: targetId, contextId: targetContextId });
1751 } catch (error) {
1752 if (
1753 this.sameBrowserId(this.switchingBrowserId, targetId)
1754 && this.normalizeContextId(this.activeBrowserContextId) === targetContextId
1755 ) {
1756 this.switchingBrowserId = null;
1757 }
1758 this.error = error instanceof Error ? error.message : String(error);
1759 }
1760 }
1761 },
1762
1763 async openNewBrowser() {
1764 await this.command("open");
1765 },
1766
1767 isClosingBrowser(id, contextId = "") {
1768 const browserId = this.normalizeBrowserId(id);
1769 const key = this.browserTabKey({
1770 id: browserId,
1771 context_id: contextId || this.contextIdForBrowserId(browserId),
1772 });
1773 return Boolean(key && this._closingBrowserIds[key]);
1774 },
1775
1776 markBrowserClosing(id, contextId = "", closing = true) {
1777 const browserId = this.normalizeBrowserId(id);
1778 if (!browserId) return;
1779 const key = this.browserTabKey({
1780 id: browserId,
1781 context_id: contextId || this.contextIdForBrowserId(browserId),
1782 });
1783 if (!key) return;
1784 const nextClosing = { ...this._closingBrowserIds };
1785 if (closing) {
1786 nextClosing[key] = true;
1787 } else {
1788 delete nextClosing[key];
1789 }
1790 this._closingBrowserIds = nextClosing;
1791 },
1792
1793 async closeBrowser(id, contextId = "") {
1794 const browserId = this.normalizeBrowserId(id);
1795 const browserContextId = this.normalizeContextId(contextId || this.contextIdForBrowserId(browserId));
1796 if (!browserId || !browserContextId || this.isClosingBrowser(browserId, browserContextId)) return;
1797 this.markBrowserClosing(browserId, browserContextId, true);
1798 try {
1799 await this.command("close", { browser_id: browserId, context_id: browserContextId });
1800 } finally {
1801 this.markBrowserClosing(browserId, browserContextId, false);
1802 }
1803 },
1804
1805 isActiveBrowser(browser) {
1806 return this.sameBrowserTab(browser?.id, browser?.context_id, this.activeBrowserId, this.activeBrowserContextId);
1807 },
1808
1809 isBrowserLoading(browser) {
1810 return Boolean(browser?.loading || (this.isActiveBrowser(browser) && this.isBusy()));
1811 },
1812
1813 browserTabTitle(browser) {
1814 const title = String(browser?.title || "").trim();
1815 const url = String(browser?.currentUrl || "").trim();
1816 return title || url || "about:blank";
1817 },
1818
1819 browserTabLabel(browser) {
1820 const id = browser?.id ? `#${browser.id}` : "Browser";
1821 return [id, this.browserTabTitle(browser)].filter(Boolean).join(" ");
1822 },
1823
1824 browserTabTooltip(browser) {
1825 const chatTitle = this.browserChatTitle(browser);
1826 return [this.browserTabLabel(browser), chatTitle ? `Chat: ${chatTitle}` : ""]
1827 .filter(Boolean)
1828 .join("\n");
1829 },
1830
1831 browserTabKey(browser = {}) {
1832 const id = this.normalizeBrowserId(browser?.id ?? browser);
1833 const contextId = this.normalizeContextId(browser?.context_id || browser?.contextId || this.activeBrowserContextId || this.contextId);
1834 return id && contextId ? `${contextId}:${id}` : "";
1835 },
1836
1837 browserChatTitle(browser = {}) {
1838 const contextId = this.normalizeContextId(browser?.context_id || browser?.contextId);
1839 if (!contextId) return "";
1840 const context = chatsStore.contexts?.find?.((item) => item?.id === contextId);
1841 return String(context?.name || context?.title || "").trim();
1842 },
1843
1844 firstBrowser(contextId = "") {
1845 const normalizedContextId = this.normalizeContextId(contextId);
1846 const browsers = Array.isArray(this.browsers) ? this.browsers : [];
1847 if (normalizedContextId) {
1848 const scoped = browsers.find((browser) => this.normalizeContextId(browser?.context_id) === normalizedContextId);
1849 if (scoped) return scoped;
1850 }
1851 return browsers[0] || null;
1852 },
1853
1854 visibleBrowsers() {
1855 const browsers = Array.isArray(this.browsers) ? this.browsers : [];
1856 if (this.tabScope === "shared") return browsers;
1857 const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId || this.resolveContextId());
1858 return contextId
1859 ? browsers.filter((browser) => this.normalizeContextId(browser?.context_id) === contextId)
1860 : browsers;
1861 },
1862
1863 firstBrowserInContext(contextId = "") {
1864 const normalizedContextId = this.normalizeContextId(contextId);
1865 if (!normalizedContextId || !Array.isArray(this.browsers)) return null;
1866 return this.browsers.find((browser) => this.normalizeContextId(browser?.context_id) === normalizedContextId) || null;
1867 },
1868
1869 firstBrowserId(contextId = "") {
1870 return this.firstBrowser(contextId)?.id || null;
1871 },
1872
1873 normalizeBrowserId(id) {
1874 return Number(id) || null;
1875 },
1876
1877 sameBrowserId(left, right) {
1878 const leftId = this.normalizeBrowserId(left);
1879 const rightId = this.normalizeBrowserId(right);
1880 return Boolean(leftId && rightId && leftId === rightId);
1881 },
1882
1883 sameBrowserTab(leftId, leftContextId, rightId, rightContextId) {
1884 return this.sameBrowserId(leftId, rightId)
1885 && this.normalizeContextId(leftContextId) === this.normalizeContextId(rightContextId);
1886 },
1887
1888 browserById(id, contextId = "") {
1889 const numeric = this.normalizeBrowserId(id);
1890 if (!numeric || !Array.isArray(this.browsers)) return null;
1891 const normalizedContextId = this.normalizeContextId(contextId);
1892 return this.browsers.find((browser) => (
1893 Number(browser?.id) === numeric
1894 && (!normalizedContextId || this.normalizeContextId(browser?.context_id) === normalizedContextId)
1895 )) || null;
1896 },
1897
1898 contextIdForBrowserId(id) {
1899 const numeric = this.normalizeBrowserId(id);
1900 if (!numeric) return "";
1901 if (this.sameBrowserId(numeric, this.activeBrowserId) && this.activeBrowserContextId) {
1902 return this.activeBrowserContextId;
1903 }
1904 return this.normalizeContextId(this.browserById(numeric)?.context_id);
1905 },
1906
1907 applyBrowserListing(browsers = [], fallbackContextId = "", options = {}) {
1908 const incoming = Array.isArray(browsers)
1909 ? browsers.map((browser) => ({
1910 ...browser,
1911 context_id: this.normalizeContextId(browser?.context_id || fallbackContextId),
1912 })).filter((browser) => browser.id && browser.context_id)
1913 : [];
1914 const incomingKeys = new Set(incoming.map((browser) => this.browserTabKey(browser)));
1915 const fallback = this.normalizeContextId(fallbackContextId);
1916 const existing = Array.isArray(this.browsers) ? this.browsers : [];
1917 const retained = options.replaceAll
1918 ? []
1919 : existing.filter((browser) => {
1920 const key = this.browserTabKey(browser);
1921 if (incomingKeys.has(key)) return false;
1922 if (options.replaceContext && fallback && this.normalizeContextId(browser?.context_id) === fallback) return false;
1923 return true;
1924 });
1925 this.browsers = [...retained, ...incoming];
1926 },
1927
1928 stateFromCommandResult(result = {}) {
1929 if (result?.state?.id || result?.state?.currentUrl || result?.state?.title) {
1930 return result.state;
1931 }
1932 if (result?.id || result?.currentUrl || result?.title) {
1933 return result;
1934 }
1935 return null;
1936 },
1937
1938 applyActiveFrameState(nextState = null) {
1939 if (!nextState) return;
1940 const stateId = this.normalizeBrowserId(nextState.id);
1941 const stateContextId = this.normalizeContextId(nextState.context_id || this.activeBrowserContextId);
1942 if (
1943 stateId
1944 && this.activeBrowserId
1945 && !this.sameBrowserTab(stateId, stateContextId, this.activeBrowserId, this.activeBrowserContextId)
1946 ) {
1947 return;
1948 }
1949 const previousUrl = String(this.frameState?.currentUrl || "");
1950 const nextUrl = String(nextState.currentUrl || "");
1951 this.frameState = nextState;
1952 if (previousUrl && nextUrl && previousUrl !== nextUrl) {
1953 this.cancelAnnotationDraft();
1954 this.clearAnnotationHover();
1955 }
1956 if (!this.addressFocused && nextState.currentUrl) {
1957 this.address = nextState.currentUrl;
1958 }
1959 },
1960
1961 applySnapshot(snapshot = null) {
1962 if (!snapshot?.image) return;
1963 const snapshotId = this.normalizeBrowserId(snapshot.browser_id || snapshot.state?.id);
1964 const snapshotContextId = this.normalizeContextId(snapshot.context_id || snapshot.state?.context_id || this.activeBrowserContextId);
1965 if (
1966 snapshotId
1967 && this.activeBrowserId
1968 && !this.sameBrowserTab(snapshotId, snapshotContextId, this.activeBrowserId, this.activeBrowserContextId)
1969 ) {
1970 return;
1971 }
1972 if (snapshot.state) {
1973 this.applyActiveFrameState(snapshot.state);
1974 }
1975 if (this.usesInteractiveTransport()) return;
1976 const frameBrowserId = snapshotId || this.activeBrowserId;
1977 this.queueFrameRender(`data:${snapshot.mime || "image/jpeg"};base64,${snapshot.image}`, {
1978 browserId: frameBrowserId,
1979 contextId: snapshotContextId,
1980 onAccepted: () => {
1981 if (
1982 this.sameBrowserId(this.switchingBrowserId, frameBrowserId)
1983 && this.normalizeContextId(this.activeBrowserContextId) === snapshotContextId
1984 ) {
1985 this.switchingBrowserId = null;
1986 }
1987 this._surfaceSwitching = false;
1988 },
1989 });
1990 },
1991
1992 isSwitchingBrowser() {
1993 return Boolean(
1994 this.switchingBrowserId
1995 && this.sameBrowserId(this.switchingBrowserId, this.activeBrowserId)
1996 && this.normalizeContextId(this.contextId) === this.normalizeContextId(this.activeBrowserContextId)
1997 );
1998 },
1999
2000 isBusy() {
2001 return Boolean(this.loading || this.commandInFlight || this._surfaceSwitching || this.isSwitchingBrowser());
2002 },
2003
2004 browserLoadingLabel() {
2005 if (this.commandInFlight && !this.activeBrowserId) return "Starting Browser…";
2006 if (this.isSwitchingBrowser()) return "Switching Browser tab…";
2007 return "Connecting to Browser…";
2008 },
2009
2010 setActiveBrowserId(id, contextId = "") {
2011 const previous = this.activeBrowserId;
2012 const previousContextId = this.activeBrowserContextId;
2013 const numeric = this.normalizeBrowserId(id);
2014 const normalizedContextId = this.normalizeContextId(contextId || this.contextIdForBrowserId(numeric));
2015 const exists = !numeric
2016 || !Array.isArray(this.browsers)
2017 || this.browsers.some((browser) => (
2018 Number(browser.id) === numeric
2019 && (!normalizedContextId || this.normalizeContextId(browser.context_id) === normalizedContextId)
2020 ));
2021 this.activeBrowserId = exists ? numeric : null;
2022 this.activeBrowserContextId = this.activeBrowserId ? normalizedContextId : "";
2023 this.contextId = this.activeBrowserContextId || this.contextId;
2024 if (this.activeBrowserId !== previous || this.activeBrowserContextId !== previousContextId) {
2025 this._lastViewportKey = "";
2026 this._lastViewport = null;
2027 this.cancelAnnotationDraft();
2028 this.clearAnnotationHover();
2029 }
2030 },
2031
2032 pointerCoordinatesFor(event, element = null) {
2033 const target = element || event?.currentTarget;
2034 if (!target) return null;
2035 const rect = target.getBoundingClientRect();
2036 const naturalWidth = target.naturalWidth || target.width || rect.width;
2037 const naturalHeight = target.naturalHeight || target.height || rect.height;
2038 let contentLeft = rect.left;
2039 let contentTop = rect.top;
2040 let contentWidth = rect.width;
2041 let contentHeight = rect.height;
2042
2043 const objectFit = globalThis.getComputedStyle?.(target)?.objectFit || "";
2044 if (
2045 target.matches?.(".browser-frame")
2046 && ["contain", "scale-down"].includes(objectFit)
2047 && naturalWidth > 0
2048 && naturalHeight > 0
2049 && rect.width > 0
2050 && rect.height > 0
2051 ) {
2052 const naturalRatio = naturalWidth / naturalHeight;
2053 const rectRatio = rect.width / rect.height;
2054 if (naturalRatio > rectRatio) {
2055 contentWidth = rect.width;
2056 contentHeight = rect.width / naturalRatio;
2057 contentTop = rect.top + (rect.height - contentHeight) / 2;
2058 } else {
2059 contentHeight = rect.height;
2060 contentWidth = rect.height * naturalRatio;
2061 contentLeft = rect.left + (rect.width - contentWidth) / 2;
2062 }
2063 }
2064
2065 const relativeX = (event.clientX - contentLeft) / Math.max(1, contentWidth);
2066 const relativeY = (event.clientY - contentTop) / Math.max(1, contentHeight);
2067 return {
2068 x: Math.max(0, Math.min(naturalWidth, relativeX * naturalWidth)),
2069 y: Math.max(0, Math.min(naturalHeight, relativeY * naturalHeight)),
2070 };
2071 },
2072
2073 handleKeydown(event) {
2074 if (isLocalEditableTarget(event?.target)) return;
2075 const annotateShortcut = event?.key === "." && (event.metaKey || event.ctrlKey) && !event.altKey;
2076 if (annotateShortcut && this._surfaceMounted) {
2077 event.preventDefault();
2078 event.stopPropagation?.();
2079 this.toggleAnnotationMode();
2080 return;
2081 }
2082
2083 if (this.annotating) {
2084 if (event?.key === "Escape") {
2085 event.preventDefault();
2086 if (this.annotationDraft || this.annotationDragRect) {
2087 this.cancelAnnotationDraft();
2088 } else {
2089 this.toggleAnnotationMode(false);
2090 }
2091 }
2092 return;
2093 }
2094
2095 if (this.handleVisualBrowserShortcut(event)) {
2096 return;
2097 }
2098
2099 void this.sendKey(event);
2100 },
2101
2102 handleVisualBrowserShortcut(event) {
2103 const shortcut = this.visualBrowserShortcut(event);
2104 if (!shortcut) return false;
2105 event.preventDefault();
2106 event.stopPropagation?.();
2107
2108 if (shortcut.action === "paste") {
2109 void this.pasteHostClipboardToBrowser();
2110 return true;
2111 }
2112 if (shortcut.action === "copy" || shortcut.action === "cut") {
2113 void this.copyBrowserClipboardToHost(shortcut.action);
2114 return true;
2115 }
2116 if (shortcut.key) {
2117 void this.sendShortcut(shortcut.key);
2118 return true;
2119 }
2120 return false;
2121 },
2122
2123 visualBrowserShortcut(event) {
2124 if (!this.shouldHandleVisualBrowserShortcut(event)) return null;
2125 const key = String(event?.key || "").toLowerCase();
2126 const primary = Boolean(event?.ctrlKey || event?.metaKey);
2127 const shift = Boolean(event?.shiftKey);
2128
2129 if (!primary && shift && key === "insert") {
2130 return { action: "paste" };
2131 }
2132 if (!primary || event?.altKey) return null;
2133
2134 if (key === "v") return { action: "paste" };
2135 if (!shift && (key === "c" || key === "insert")) return { action: "copy" };
2136 if (!shift && key === "x") return { action: "cut" };
2137 if (!shift && key === "a") return { key: "Control+A" };
2138 if (key === "z") return { key: shift ? "Control+Shift+Z" : "Control+Z" };
2139 if (!shift && key === "y") return { key: "Control+Y" };
2140 return null;
2141 },
2142
2143 shouldHandleVisualBrowserShortcut(event) {
2144 if (!this._surfaceMounted || !this.activeBrowserId || this.annotating) return false;
2145 if (isLocalEditableTarget(event?.target)) return false;
2146 const key = String(event?.key || "").toLowerCase();
2147 if (!BROWSER_VISUAL_SHORTCUT_KEYS.has(key)) return false;
2148 return Boolean(this.visualBrowserStageForEvent(event));
2149 },
2150
2151 visualBrowserStageForEvent(event) {
2152 const element = elementFromTarget(event?.target);
2153 const blockingUi = element?.closest?.(
2154 ".browser-toolbar, .browser-meta, .browser-extension-dropdown, .browser-annotation-popover, .browser-annotation-tray, button, a",
2155 );
2156 if (blockingUi) return null;
2157
2158 const stage = element?.closest?.(".browser-stage");
2159 if (stage?.closest?.(".browser-panel")) return stage;
2160
2161 const activeElement = globalThis.document?.activeElement;
2162 const activeStage = activeElement?.closest?.(".browser-stage");
2163 if (activeStage?.closest?.(".browser-panel")) return activeStage;
2164 return null;
2165 },
2166
2167 handleStageWheel(event) {
2168 if (this.annotating) return;
2169 void this.sendWheel(event);
2170 },
2171
2172 toggleAnnotationMode(force = null) {
2173 const nextValue = force === null ? !this.annotating : Boolean(force);
2174 if (nextValue && !this.canAnnotate()) return;
2175
2176 this.annotating = nextValue;
2177 this.annotationError = "";
2178 this.closeExtensionsMenu();
2179 if (!nextValue) {
2180 this.cancelAnnotationDraft();
2181 this.clearAnnotationHover();
2182 this.annotationDragRect = null;
2183 this._annotationPointer = null;
2184 } else {
2185 this._stageElement?.focus?.({ preventScroll: true });
2186 }
2187 },
2188
2189 canAnnotate() {
2190 return Boolean(this.activeBrowserId && this.hasFrame() && !this.isBusy());
2191 },
2192
2193 activeAnnotationUrl() {
2194 return String(this.frameState?.currentUrl || this.address || "about:blank");
2195 },
2196
2197 visibleAnnotations() {
2198 const browserId = this.normalizeBrowserId(this.activeBrowserId);
2199 const contextId = this.normalizeContextId(this.activeBrowserContextId);
2200 const url = this.activeAnnotationUrl();
2201 return this.annotationComments.filter((annotation) => (
2202 this.sameBrowserTab(annotation.browserId, annotation.contextId, browserId, contextId)
2203 && String(annotation.url || "") === url
2204 ));
2205 },
2206
2207 pendingAnnotations() {
2208 const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
2209 if (!contextId) return [];
2210 return this.annotationComments.filter(
2211 (annotation) => this.normalizeContextId(annotation.contextId) === contextId,
2212 );
2213 },
2214
2215 nextAnnotationIndex() {
2216 return this.pendingAnnotations().length + 1;
2217 },
2218
2219 annotationBatchLabel() {
2220 const annotations = this.pendingAnnotations();
2221 const pageCount = new Set(
2222 annotations.map((annotation) => `${annotation.browserId}:${annotation.url}`),
2223 ).size;
2224 if (pageCount <= 1) return `Annotations (${annotations.length})`;
2225 return `Annotations (${annotations.length} across ${pageCount} pages)`;
2226 },
2227
2228 annotationTrayStyle() {
2229 if (!this.annotationTrayPosition) return {};
2230 const position = this.clampAnnotationTrayPosition(this.annotationTrayPosition);
2231 return {
2232 left: `${position.x}px`,
2233 top: `${position.y}px`,
2234 right: "auto",
2235 bottom: "auto",
2236 };
2237 },
2238
2239 clampAnnotationTrayPosition(position = {}) {
2240 const stageRect = this._stageElement?.getBoundingClientRect?.();
2241 const stageWidth = Math.max(1, Number(stageRect?.width || 0));
2242 const stageHeight = Math.max(1, Number(stageRect?.height || 0));
2243 const width = Math.max(180, Number(position.width || 0));
2244 const height = Math.max(90, Number(position.height || 0));
2245 const maxX = Math.max(ANNOTATION_TRAY_MARGIN, stageWidth - width - ANNOTATION_TRAY_MARGIN);
2246 const maxY = Math.max(ANNOTATION_TRAY_MARGIN, stageHeight - height - ANNOTATION_TRAY_MARGIN);
2247 return {
2248 x: Math.min(Math.max(ANNOTATION_TRAY_MARGIN, Number(position.x || 0)), maxX),
2249 y: Math.min(Math.max(ANNOTATION_TRAY_MARGIN, Number(position.y || 0)), maxY),
2250 width,
2251 height,
2252 };
2253 },
2254
2255 startAnnotationTrayDrag(event) {
2256 if (event.button !== 0) return;
2257 if (event.target?.closest?.("button, input, select, textarea, a")) return;
2258 const tray = event.currentTarget?.closest?.(".browser-annotation-tray");
2259 const stageRect = this._stageElement?.getBoundingClientRect?.();
2260 const trayRect = tray?.getBoundingClientRect?.();
2261 if (!tray || !stageRect || !trayRect) return;
2262
2263 const position = this.clampAnnotationTrayPosition({
2264 x: trayRect.left - stageRect.left,
2265 y: trayRect.top - stageRect.top,
2266 width: trayRect.width,
2267 height: trayRect.height,
2268 });
2269 this.annotationTrayPosition = position;
2270 this.annotationTrayDragging = true;
2271 this._annotationTrayDrag = {
2272 id: event.pointerId,
2273 target: event.currentTarget,
2274 x: event.clientX,
2275 y: event.clientY,
2276 startX: position.x,
2277 startY: position.y,
2278 width: position.width,
2279 height: position.height,
2280 };
2281 event.currentTarget?.setPointerCapture?.(event.pointerId);
2282 event.preventDefault();
2283 },
2284
2285 moveAnnotationTrayDrag(event) {
2286 const drag = this._annotationTrayDrag;
2287 if (!drag || event.pointerId !== drag.id) return;
2288 this.annotationTrayPosition = this.clampAnnotationTrayPosition({
2289 x: drag.startX + event.clientX - drag.x,
2290 y: drag.startY + event.clientY - drag.y,
2291 width: drag.width,
2292 height: drag.height,
2293 });
2294 event.preventDefault();
2295 },
2296
2297 finishAnnotationTrayDrag(event = null) {
2298 const drag = this._annotationTrayDrag;
2299 if (!drag || (event?.pointerId && event.pointerId !== drag.id)) return;
2300 try {
2301 drag.target?.releasePointerCapture?.(drag.id);
2302 } catch {}
2303 this._annotationTrayDrag = null;
2304 this.annotationTrayDragging = false;
2305 },
2306
2307 resetAnnotationTrayPosition() {
2308 this.finishAnnotationTrayDrag();
2309 this.annotationTrayPosition = null;
2310 },
2311
2312 clearPendingAnnotations() {
2313 const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
2314 if (!contextId) return;
2315 this.annotationComments = this.annotationComments.filter(
2316 (annotation) => this.normalizeContextId(annotation.contextId) !== contextId,
2317 );
2318 this.resetAnnotationTrayPosition();
2319 },
2320
2321 annotationBoxStyle(rect = {}) {
2322 const viewport = this.currentViewportSize() || this._lastViewport || {};
2323 const width = Math.max(1, Number(viewport.width || rect.width || 1));
2324 const height = Math.max(1, Number(viewport.height || rect.height || 1));
2325 const normalized = this.clampAnnotationRect(rect);
2326 return [
2327 `left: ${(normalized.x / width) * 100}%`,
2328 `top: ${(normalized.y / height) * 100}%`,
2329 `width: ${(Math.max(1, normalized.width) / width) * 100}%`,
2330 `height: ${(Math.max(1, normalized.height) / height) * 100}%`,
2331 ].join("; ");
2332 },
2333
2334 annotationPopoverStyle() {
2335 const rect = this.annotationDraft?.rect || this.annotationDragRect || {};
2336 const viewport = this.currentViewportSize() || this._lastViewport || {};
2337 const width = Math.max(1, Number(viewport.width || 1));
2338 const height = Math.max(1, Number(viewport.height || 1));
2339 const popoverWidth = Math.min(320, Math.max(240, width - 20));
2340 const popoverHeight = 190;
2341 const nextLeft = Math.min(
2342 Math.max(10, Number(rect.x || 0) + Number(rect.width || 0) + 10),
2343 Math.max(10, width - popoverWidth - 10),
2344 );
2345 const nextTop = Math.min(
2346 Math.max(10, Number(rect.y || 0) + Number(rect.height || 0) + 10),
2347 Math.max(10, height - popoverHeight - 10),
2348 );
2349 return [
2350 `left: ${(nextLeft / width) * 100}%`,
2351 `top: ${(nextTop / height) * 100}%`,
2352 `width: min(${popoverWidth}px, calc(100% - 20px))`,
2353 ].join("; ");
2354 },
2355
2356 annotationDraftTitle() {
2357 if (!this.annotationDraft) return "Annotation";
2358 return this.annotationDraft.kind === "area" ? "Area annotation" : "Element annotation";
2359 },
2360
2361 stagePointForEvent(event) {
2362 return this.pointerCoordinatesFor(event, this.frameElement());
2363 },
2364
2365 normalizeAnnotationRect(start = {}, end = {}) {
2366 const x1 = Number(start.x || 0);
2367 const y1 = Number(start.y || 0);
2368 const x2 = Number(end.x || x1);
2369 const y2 = Number(end.y || y1);
2370 return this.clampAnnotationRect({
2371 x: Math.min(x1, x2),
2372 y: Math.min(y1, y2),
2373 width: Math.abs(x2 - x1),
2374 height: Math.abs(y2 - y1),
2375 });
2376 },
2377
2378 clampAnnotationRect(rect = {}) {
2379 const viewport = this.currentViewportSize() || this._lastViewport || {};
2380 const viewportWidth = Math.max(1, Number(viewport.width || rect.x + rect.width || 1));
2381 const viewportHeight = Math.max(1, Number(viewport.height || rect.y + rect.height || 1));
2382 const x = Math.max(0, Math.min(viewportWidth, Number(rect.x || 0)));
2383 const y = Math.max(0, Math.min(viewportHeight, Number(rect.y || 0)));
2384 const width = Math.max(1, Math.min(viewportWidth - x, Number(rect.width || 1)));
2385 const height = Math.max(1, Math.min(viewportHeight - y, Number(rect.height || 1)));
2386 return {
2387 x: Math.round(x),
2388 y: Math.round(y),
2389 width: Math.round(width),
2390 height: Math.round(height),
2391 };
2392 },
2393
2394 startAnnotationSelection(event) {
2395 if (!this.annotating || this.annotationBusy || !this.canAnnotate()) return;
2396 const point = this.stagePointForEvent(event);
2397 if (!point) return;
2398 this.cancelAnnotationDraft();
2399 this.clearAnnotationHover();
2400 this.annotationError = "";
2401 this._annotationPointer = {
2402 id: event.pointerId,
2403 start: point,
2404 last: point,
2405 };
2406 this.annotationDragRect = this.clampAnnotationRect({
2407 x: point.x,
2408 y: point.y,
2409 width: 1,
2410 height: 1,
2411 });
2412 event.currentTarget?.setPointerCapture?.(event.pointerId);
2413 },
2414
2415 moveAnnotationSelection(event) {
2416 if (!this.annotating) return;
2417 if (!this._annotationPointer) {
2418 void this.updateAnnotationHover(event);
2419 return;
2420 }
2421 if (event.pointerId !== this._annotationPointer.id) return;
2422 const point = this.stagePointForEvent(event);
2423 if (!point) return;
2424 this._annotationPointer.last = point;
2425 this.annotationDragRect = this.normalizeAnnotationRect(this._annotationPointer.start, point);
2426 },
2427
2428 async finishAnnotationSelection(event) {
2429 if (!this.annotating || !this._annotationPointer) return;
2430 if (event.pointerId !== this._annotationPointer.id) return;
2431 const pointer = this._annotationPointer;
2432 this._annotationPointer = null;
2433 event.currentTarget?.releasePointerCapture?.(event.pointerId);
2434 const endPoint = this.stagePointForEvent(event) || pointer.last || pointer.start;
2435 const rect = this.normalizeAnnotationRect(pointer.start, endPoint);
2436 this.annotationDragRect = null;
2437 const isDrag = rect.width >= ANNOTATION_DRAG_THRESHOLD || rect.height >= ANNOTATION_DRAG_THRESHOLD;
2438 const point = {
2439 x: Math.round(endPoint.x),
2440 y: Math.round(endPoint.y),
2441 };
2442 const payload = {
2443 kind: isDrag ? "area" : "element",
2444 point,
2445 rect: isDrag ? rect : null,
2446 viewport: this.currentViewportSize(),
2447 url: this.activeAnnotationUrl(),
2448 title: this.activeTitle,
2449 };
2450 await this.createAnnotationDraft(payload, isDrag ? rect : {
2451 x: point.x - 10,
2452 y: point.y - 10,
2453 width: 20,
2454 height: 20,
2455 });
2456 },
2457
2458 cancelAnnotationSelection(event = null) {
2459 if (event && this._annotationPointer?.id === event.pointerId) {
2460 event.currentTarget?.releasePointerCapture?.(event.pointerId);
2461 }
2462 this._annotationPointer = null;
2463 this.annotationDragRect = null;
2464 },
2465
2466 clearAnnotationHover() {
2467 this._annotationHoverSequence += 1;
2468 this.annotationHover = null;
2469 },
2470
2471 async updateAnnotationHover(event) {
2472 if (!this.annotating || this.annotationBusy || this.annotationDraft || this._annotationPointer) return;
2473 const now = Date.now();
2474 if (now - this._annotationHoverAt < 90) return;
2475 const point = this.stagePointForEvent(event);
2476 const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
2477 const browserId = this.activeBrowserId;
2478 if (!point || !contextId || !browserId) return;
2479
2480 this._annotationHoverAt = now;
2481 const url = this.activeAnnotationUrl();
2482 const sequence = this._annotationHoverSequence + 1;
2483 this._annotationHoverSequence = sequence;
2484 try {
2485 const response = await websocket.request(
2486 "browser_viewer_annotation",
2487 {
2488 context_id: contextId,
2489 browser_id: browserId,
2490 viewer_id: this._viewerToken,
2491 payload: {
2492 kind: "element",
2493 point: { x: Math.round(point.x), y: Math.round(point.y) },
2494 viewport: this.currentViewportSize(),
2495 url,
2496 title: this.activeTitle,
2497 },
2498 },
2499 { timeoutMs: 10000 },
2500 );
2501 if (
2502 sequence !== this._annotationHoverSequence
2503 || !this.sameBrowserTab(browserId, contextId, this.activeBrowserId, this.activeBrowserContextId)
2504 || url !== this.activeAnnotationUrl()
2505 ) return;
2506 const metadata = firstOk(response).annotation || {};
2507 const rect = metadata?.target?.rect || metadata?.rect;
2508 this.annotationHover = rect
2509 ? { rect: this.clampAnnotationRect(rect), metadata }
2510 : null;
2511 } catch {
2512 if (sequence === this._annotationHoverSequence) this.annotationHover = null;
2513 }
2514 },
2515
2516 annotationHoverLabel() {
2517 const target = this.annotationHover?.metadata?.target || {};
2518 const tag = String(target.tagName || "").toLowerCase();
2519 const summary = String(target.summary || "").trim();
2520 return [tag ? `<${tag}>` : "Element", summary].filter(Boolean).join(" ");
2521 },
2522
2523 cancelAnnotationDraft() {
2524 this.annotationDraft = null;
2525 this.annotationDraftText = "";
2526 this.annotationDragRect = null;
2527 },
2528
2529 async createAnnotationDraft(payload, fallbackRect) {
2530 const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
2531 if (!this.activeBrowserId || !contextId) return;
2532 const sequence = this._annotationSequence + 1;
2533 const browserId = this.activeBrowserId;
2534 const url = this.activeAnnotationUrl();
2535 const title = this.activeTitle;
2536 this._annotationSequence = sequence;
2537 this.clearAnnotationHover();
2538 this.annotationBusy = true;
2539 this.annotationError = "";
2540 try {
2541 const response = await websocket.request(
2542 "browser_viewer_annotation",
2543 {
2544 context_id: contextId,
2545 browser_id: browserId,
2546 viewer_id: this._viewerToken,
2547 payload,
2548 },
2549 { timeoutMs: 10000 },
2550 );
2551 if (sequence !== this._annotationSequence) return;
2552 const data = firstOk(response);
2553 const metadata = data.annotation || {};
2554 this.annotationDraft = {
2555 id: makeViewerToken(),
2556 browserId,
2557 contextId,
2558 url,
2559 title,
2560 kind: metadata.kind || payload.kind,
2561 rect: this.annotationRectFromMetadata(metadata, fallbackRect),
2562 metadata,
2563 createdAt: Date.now(),
2564 };
2565 this.annotationDraftText = "";
2566 } catch (error) {
2567 this.annotationError = error instanceof Error ? error.message : String(error);
2568 this.error = this.annotationError;
2569 } finally {
2570 if (sequence === this._annotationSequence) {
2571 this.annotationBusy = false;
2572 }
2573 }
2574 },
2575
2576 annotationRectFromMetadata(metadata = {}, fallbackRect = {}) {
2577 const targetRect = metadata?.target?.rect || metadata?.rect || null;
2578 return this.clampAnnotationRect(targetRect || fallbackRect);
2579 },
2580
2581 addAnnotationComment() {
2582 const comment = String(this.annotationDraftText || "").trim();
2583 if (!this.annotationDraft || !comment) return;
2584 if (this.pendingAnnotations().length >= ANNOTATION_MAX_COMMENTS) {
2585 this.annotationError = `Keep each batch to ${ANNOTATION_MAX_COMMENTS} annotations or fewer.`;
2586 this.error = this.annotationError;
2587 return;
2588 }
2589 this.annotationComments = [
2590 ...this.annotationComments,
2591 {
2592 ...this.annotationDraft,
2593 comment,
2594 index: this.nextAnnotationIndex(),
2595 },
2596 ];
2597 this.cancelAnnotationDraft();
2598 },
2599
2600 removeAnnotationComment(annotationId) {
2601 this.annotationComments = this.annotationComments.filter((annotation) => annotation.id !== annotationId);
2602 if (!this.pendingAnnotations().length) {
2603 this.resetAnnotationTrayPosition();
2604 }
2605 },
2606
2607 formatAnnotationRect(rect = {}) {
2608 const normalized = {
2609 x: Math.round(Number(rect.x || 0)),
2610 y: Math.round(Number(rect.y || 0)),
2611 width: Math.max(1, Math.round(Number(rect.width || 1))),
2612 height: Math.max(1, Math.round(Number(rect.height || 1))),
2613 };
2614 return `x=${normalized.x}, y=${normalized.y}, width=${normalized.width}, height=${normalized.height}`;
2615 },
2616
2617 redactAnnotationText(value) {
2618 return String(value || "")
2619 .replace(/(<input\b(?=[^>]*\btype=(["'])?password\2?)[^>]*?)\svalue=(["'])[\s\S]*?\3/giu, "$1 value=\"[redacted]\"")
2620 .replace(/\b(password|passcode|token|secret|value)=((["'])[\s\S]{1,240}?\3)/giu, "$1=\"[redacted]\"");
2621 },
2622
2623 formatAnnotationMetadata(metadata = {}) {
2624 const lines = [];
2625 const target = metadata.target || {};
2626 const selector = target.selector || metadata.selector || "";
2627 const summary = target.summary || metadata.summary || "";
2628 const dom = this.redactAnnotationText(target.dom || metadata.dom || "").slice(0, ANNOTATION_DOM_LIMIT);
2629
2630 if (selector) {
2631 lines.push(`Selector: ${selector}`);
2632 }
2633 if (target.tagName || target.role || target.id || target.name || target.classes) {
2634 lines.push([
2635 "Element:",
2636 target.tagName ? `<${String(target.tagName).toLowerCase()}>` : "",
2637 target.role ? `role=${target.role}` : "",
2638 target.id ? `id=${target.id}` : "",
2639 target.name ? `name=${target.name}` : "",
2640 target.classes ? `class=${target.classes}` : "",
2641 ].filter(Boolean).join(" "));
2642 }
2643 if (summary) {
2644 lines.push(`Summary: ${summary}`);
2645 }
2646 if (Array.isArray(metadata.elements) && metadata.elements.length) {
2647 lines.push("Intersecting elements:");
2648 metadata.elements.slice(0, 8).forEach((element, index) => {
2649 const elementLabel = [
2650 `${index + 1}.`,
2651 element.tagName ? `<${String(element.tagName).toLowerCase()}>` : "",
2652 element.selector || "",
2653 element.summary || "",
2654 ].filter(Boolean).join(" ");
2655 lines.push(elementLabel);
2656 });
2657 }
2658 if (dom) {
2659 lines.push(`DOM: ${dom}`);
2660 }
2661 return lines.join("\n");
2662 },
2663
2664 buildAnnotationsPrompt(instruction = "") {
2665 const annotations = this.pendingAnnotations();
2666 if (!annotations.length) return "";
2667 const lines = ["Browser annotations"];
2668 const spokenInstruction = String(instruction || "").trim();
2669 if (spokenInstruction) lines.push(`Instruction: ${spokenInstruction}`);
2670 lines.push("");
2671
2672 const pages = new Map();
2673 for (const annotation of annotations) {
2674 const key = `${annotation.contextId}:${annotation.browserId}:${annotation.url}`;
2675 if (!pages.has(key)) pages.set(key, []);
2676 pages.get(key).push(annotation);
2677 }
2678
2679 let annotationNumber = 0;
2680 Array.from(pages.values()).forEach((pageAnnotations, pageIndex) => {
2681 const page = pageAnnotations[0];
2682 lines.push(
2683 `Page ${pageIndex + 1}`,
2684 `Page title: ${page.title || "Untitled"}`,
2685 `Page URL: ${page.url || "about:blank"}`,
2686 `Browser id: ${page.browserId}`,
2687 "",
2688 );
2689 pageAnnotations.forEach((annotation) => {
2690 annotationNumber += 1;
2691 lines.push(
2692 `Annotation ${annotationNumber}`,
2693 `Comment: ${annotation.comment}`,
2694 `Selection kind: ${annotation.kind}`,
2695 `Coordinates: ${this.formatAnnotationRect(annotation.rect)}`,
2696 );
2697 const metadata = this.formatAnnotationMetadata(annotation.metadata);
2698 if (metadata) lines.push(metadata);
2699 lines.push("");
2700 });
2701 });
2702 return lines.join("\n").trim();
2703 },
2704
2705 draftAnnotationsToChat(instruction = "") {
2706 const prompt = this.buildAnnotationsPrompt(instruction);
2707 if (!prompt) return;
2708 const existingMessage = String(chatInputStore.message || "").trim();
2709 chatInputStore.message = existingMessage ? `${existingMessage}\n\n${prompt}` : prompt;
2710 chatInputStore.adjustTextareaHeight?.();
2711 chatInputStore.focus?.();
2712 this.clearPendingAnnotations();
2713 this.toggleAnnotationMode(false);
2714 },
2715
2716 async sendAnnotationsToChat(instruction = "") {
2717 const prompt = this.buildAnnotationsPrompt(instruction);
2718 if (!prompt) return;
2719 chatInputStore.message = prompt;
2720 chatInputStore.adjustTextareaHeight?.();
2721 try {
2722 if (typeof chatInputStore.sendMessage === "function") {
2723 await chatInputStore.sendMessage();
2724 } else if (typeof globalThis.sendMessage === "function") {
2725 await globalThis.sendMessage();
2726 } else {
2727 chatInputStore.focus?.();
2728 return;
2729 }
2730 this.clearPendingAnnotations();
2731 this.toggleAnnotationMode(false);
2732 } catch (error) {
2733 this.error = error instanceof Error ? error.message : String(error);
2734 }
2735 },
2736
2737 async startAnnotationVoice(draftComment = false) {
2738 try {
2739 const { store: whisperStore } = await import(
2740 "/plugins/_whisper_stt/webui/whisper-stt-store.js"
2741 );
2742 await whisperStore.handleMicrophoneClick(async (text, options = {}) => {
2743 if (draftComment) {
2744 const transcript = String(text || "").trim();
2745 if (transcript && this.annotationDraft) {
2746 const existing = String(this.annotationDraftText || "").trim();
2747 this.annotationDraftText = existing ? `${existing}\n${transcript}` : transcript;
2748 if (options.sendImmediately) {
2749 this.addAnnotationComment();
2750 await this.sendAnnotationsToChat();
2751 }
2752 }
2753 } else if (options.sendImmediately) {
2754 await this.sendAnnotationsToChat(text);
2755 } else {
2756 this.draftAnnotationsToChat(text);
2757 }
2758 whisperStore.stop();
2759 });
2760 whisperStore.updateMicrophoneButtonUI();
2761 } catch (error) {
2762 this.error = error instanceof Error ? error.message : String(error);
2763 }
2764 },
2765
2766 async syncAnnotationMicrophoneUI() {
2767 try {
2768 const { store: whisperStore } = await import(
2769 "/plugins/_whisper_stt/webui/whisper-stt-store.js"
2770 );
2771 await whisperStore.ensureStatusLoaded({ suppressError: true });
2772 whisperStore.updateMicrophoneButtonUI();
2773 } catch {}
2774 },
2775
2776 currentViewportSize() {
2777 const measurement = this.surfaceViewportMeasurement();
2778 if (!measurement) return null;
2779 return {
2780 width: measurement.width,
2781 height: measurement.height,
2782 };
2783 },
2784
2785 surfaceViewportMeasurement() {
2786 const stage = this._stageElement;
2787 if (!stage) return null;
2788 const rect = stage.getBoundingClientRect?.();
2789 const rawWidth = Math.round(rect?.width || stage.clientWidth || 0);
2790 const rawHeight = Math.round(rect?.height || stage.clientHeight || 0);
2791 if (rawWidth < 80 || rawHeight < 80) return null;
2792 return {
2793 rawWidth,
2794 rawHeight,
2795 width: Math.max(320, rawWidth),
2796 height: Math.max(200, rawHeight),
2797 };
2798 },
2799
2800 queueViewportSync(force = false) {
2801 this.clearRenderedFrameIfViewportChanged();
2802 if (force) {
2803 if (this._viewportSyncTimer) {
2804 globalThis.clearTimeout(this._viewportSyncTimer);
2805 this._viewportSyncTimer = null;
2806 }
2807 void this.syncViewport(true);
2808 return;
2809 }
2810 if (this._viewportSyncTimer) {
2811 return;
2812 }
2813 this._viewportSyncTimer = globalThis.setTimeout(() => {
2814 this._viewportSyncTimer = null;
2815 void this.syncViewport(false);
2816 }, VIEWPORT_SYNC_INTERVAL_MS);
2817 },
2818
2819 async syncViewport(force = false, options = {}) {
2820 const restartStream = Boolean(options.restartStream);
2821 const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
2822 if (!contextId || !this.activeBrowserId) {
2823 return;
2824 }
2825 const viewport = this.currentViewportSize();
2826 if (!viewport) {
2827 return;
2828 }
2829 const key = `${contextId}:${this.activeBrowserId}:${viewport.width}x${viewport.height}`;
2830 if (
2831 (!force && !restartStream && this._lastViewportKey === key)
2832 || (
2833 !force
2834 && !restartStream
2835 && this._lastViewport
2836 && this.sameBrowserTab(this._lastViewport.browserId, this._lastViewport.contextId, this.activeBrowserId, contextId)
2837 && Math.abs(this._lastViewport.width - viewport.width) <= VIEWPORT_SYNC_SIZE_TOLERANCE
2838 && Math.abs(this._lastViewport.height - viewport.height) <= VIEWPORT_SYNC_SIZE_TOLERANCE
2839 )
2840 ) {
2841 return;
2842 }
2843 try {
2844 this.syncInteractiveViewSize();
2845 await websocket.emit("browser_viewer_input", {
2846 context_id: contextId,
2847 browser_id: this.activeBrowserId,
2848 viewer_id: this._viewerToken,
2849 input_type: "viewport",
2850 viewer_transport: this.viewerTransport,
2851 width: viewport.width,
2852 height: viewport.height,
2853 restart_stream: restartStream && this.usesScreencastTransport(),
2854 });
2855 this._lastViewportKey = key;
2856 this._lastViewport = {
2857 browserId: this.activeBrowserId,
2858 contextId,
2859 width: viewport.width,
2860 height: viewport.height,
2861 };
2862 } catch (error) {
2863 this._lastViewportKey = "";
2864 this._lastViewport = null;
2865 console.warn("Browser viewport sync failed", error);
2866 }
2867 },
2868
2869 async sendMouse(eventType, event) {
2870 if (this.annotating) return;
2871 const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
2872 if (!contextId || !this.activeBrowserId || !event?.currentTarget) return;
2873 const pointer = this.pointerCoordinatesFor(event);
2874 if (!pointer) return;
2875 const payload = {
2876 context_id: contextId,
2877 browser_id: this.activeBrowserId,
2878 viewer_id: this._viewerToken,
2879 input_type: "mouse",
2880 event_type: eventType,
2881 x: pointer.x,
2882 y: pointer.y,
2883 button: "left",
2884 };
2885 if (eventType === "click") {
2886 try {
2887 const response = await websocket.request("browser_viewer_input", payload, { timeoutMs: 10000 });
2888 const data = firstOk(response);
2889 this.applyActiveFrameState(data.state);
2890 if (!this.frameCanvasReady || !this.usesScreencastTransport()) {
2891 this.applySnapshot(data.snapshot);
2892 }
2893 } catch (error) {
2894 this.error = error instanceof Error ? error.message : String(error);
2895 }
2896 return;
2897 }
2898 await websocket.emit("browser_viewer_input", payload);
2899 },
2900
2901 async sendWheel(event) {
2902 const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
2903 if (!contextId || !this.activeBrowserId || !event) return;
2904 const pointer = this.pointerCoordinatesFor(event, this.frameElement());
2905 if (!pointer) return;
2906 const payload = {
2907 context_id: contextId,
2908 browser_id: this.activeBrowserId,
2909 viewer_id: this._viewerToken,
2910 input_type: "wheel",
2911 x: pointer.x,
2912 y: pointer.y,
2913 delta_x: Number(event.deltaX || 0),
2914 delta_y: Number(event.deltaY || 0),
2915 };
2916 try {
2917 await websocket.emit("browser_viewer_input", payload);
2918 } catch (error) {
2919 this.error = error instanceof Error ? error.message : String(error);
2920 }
2921 },
2922
2923 async sendKey(event) {
2924 if (this.annotating) return;
2925 const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
2926 if (!contextId || !this.activeBrowserId) return;
2927 const printable = event.key && event.key.length === 1;
2928 const altText = isAltTextInput(event);
2929 if ((event.ctrlKey || event.metaKey || event.altKey) && !altText) return;
2930 if (isLocalEditableTarget(event?.target)) return;
2931 event.preventDefault();
2932 await websocket.emit("browser_viewer_input", {
2933 context_id: contextId,
2934 browser_id: this.activeBrowserId,
2935 input_type: "keyboard",
2936 key: printable ? "" : event.key,
2937 text: printable ? event.key : "",
2938 });
2939 },
2940
2941 async sendShortcut(key) {
2942 const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
2943 if (!contextId || !this.activeBrowserId || !key) return;
2944 await websocket.emit("browser_viewer_input", {
2945 context_id: contextId,
2946 browser_id: this.activeBrowserId,
2947 viewer_id: this._viewerToken,
2948 input_type: "keyboard",
2949 key,
2950 text: "",
2951 });
2952 },
2953
2954 async pasteHostClipboardToBrowser() {
2955 try {
2956 const text = await this.readHostClipboardText();
2957 if (!text) return;
2958 await this.sendClipboard("paste", text);
2959 } catch (error) {
2960 this.error = "Browser paste needs clipboard permission in this tab.";
2961 globalThis.justToast?.(this.error, "warning", 2200, "browser-clipboard");
2962 console.warn("Browser clipboard paste failed", error);
2963 }
2964 },
2965
2966 async copyBrowserClipboardToHost(action = "copy") {
2967 try {
2968 const clipboard = await this.sendClipboard(action);
2969 const text = String(clipboard?.text || clipboard?.clipboard_text || "");
2970 if (!text) return;
2971 await copyToClipboard(text);
2972 this._clipboardFallbackText = text;
2973 const message = action === "cut" ? "Cut from Browser" : "Copied from Browser";
2974 globalThis.justToast?.(message, "success", 1200, "browser-clipboard");
2975 } catch (error) {
2976 this.error = action === "cut"
2977 ? "Browser cut failed."
2978 : "Browser copy failed.";
2979 globalThis.justToast?.(this.error, "warning", 1800, "browser-clipboard");
2980 console.warn("Browser clipboard copy failed", error);
2981 }
2982 },
2983
2984 async readHostClipboardText() {
2985 const clipboard = globalThis.navigator?.clipboard;
2986 if (clipboard?.readText && globalThis.isSecureContext) {
2987 try {
2988 return await clipboard.readText();
2989 } catch (error) {
2990 if (this._clipboardFallbackText) return this._clipboardFallbackText;
2991 throw error;
2992 }
2993 }
2994 return this._clipboardFallbackText || "";
2995 },
2996
2997 async sendClipboard(action = "copy", text = "") {
2998 const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
2999 if (!contextId || !this.activeBrowserId) return {};
3000 const response = await websocket.request(
3001 "browser_viewer_input",
3002 {
3003 context_id: contextId,
3004 browser_id: this.activeBrowserId,
3005 viewer_id: this._viewerToken,
3006 input_type: "clipboard",
3007 action,
3008 text,
3009 },
3010 { timeoutMs: 10000 },
3011 );
3012 const data = firstOk(response);
3013 this.applyActiveFrameState(data.state);
3014 this.applySnapshot(data.snapshot);
3015 return data.clipboard || {};
3016 },
3017
3018 async cleanup() {
3019 if (this._surfaceHandoff) {
3020 this.releaseSurfaceBindings();
3021 this.extensionMenuOpen = false;
3022 return;
3023 }
3024 this._surfaceOpenSequence += 1;
3025 this._openPromise = null;
3026 this._openSignature = "";
3027 this._connectSequence += 1;
3028 this._viewerToken = "";
3029 this.switchingBrowserId = null;
3030 this.viewerTransport = this.requestedViewerTransport();
3031 this._subscribedViewerTransport = this.viewerTransport;
3032 this.interactiveViewUrl = "";
3033 this.viewerFallbackReason = "";
3034 this.tabScope = "per_context";
3035 this._surfaceMounted = false;
3036 this._surfaceSwitching = false;
3037 this.commandInFlight = false;
3038 this._commandInFlightCount = 0;
3039 this._closingBrowserIds = {};
3040 this.annotating = false;
3041 this.annotationBusy = false;
3042 this.annotationError = "";
3043 this.cancelAnnotationDraft();
3044 this.cancelAnnotationSelection();
3045 this.clearAnnotationHover();
3046 this.resetAnnotationTrayPosition();
3047 if (this.contextId) {
3048 try {
3049 await websocket.emit("browser_viewer_unsubscribe", { context_id: this.contextId });
3050 } catch {}
3051 }
3052 this._frameOff?.();
3053 this._stateOff?.();
3054 this._frameOff = null;
3055 this._stateOff = null;
3056 this.resetRenderedFrame();
3057 this.releaseSurfaceBindings();
3058 if (this._viewportSyncTimer) {
3059 globalThis.clearTimeout(this._viewportSyncTimer);
3060 this._viewportSyncTimer = null;
3061 }
3062 this.resetViewportTracking();
3063 this.extensionMenuOpen = false;
3064 this.extensionActionLoading = false;
3065 this.extensionsListLoading = false;
3066 this.extensionToggleLoadingPath = "";
3067 this.modelPresetSaving = false;
3068 this.connected = false;
3069 },
3070
3071 setupFloatingModal(element = null) {
3072 this._floatingCleanup?.();
3073 const root = element || globalThis.document?.querySelector(".browser-panel");
3074 const modal = root?.closest?.(".modal");
3075 const inner = modal?.querySelector?.(".modal-inner");
3076 const body = modal?.querySelector?.(".modal-bd");
3077 const header = modal?.querySelector?.(".modal-header");
3078 const stage = root?.querySelector?.(".browser-stage");
3079 if (!modal || !inner || !header) return;
3080 modal.classList.add("surface-floating", "modal-floating");
3081 inner.classList.add("surface-modal", "browser-modal");
3082 body?.classList?.add("browser-modal-body");
3083 this._stageElement = stage || null;
3084
3085 const rect = inner.getBoundingClientRect();
3086 inner.style.left = `${Math.max(8, rect.left)}px`;
3087 inner.style.top = `${Math.max(8, rect.top)}px`;
3088 inner.style.transform = "none";
3089
3090 let drag = null;
3091 let resizeObserver = null;
3092 let beforeFocusBounds = null;
3093 const viewportGap = 8;
3094 const currentBounds = () => {
3095 const bounds = inner.getBoundingClientRect();
3096 return {
3097 left: bounds.left,
3098 top: bounds.top,
3099 width: bounds.width,
3100 height: bounds.height,
3101 };
3102 };
3103 const normalizedBounds = (bounds = {}) => {
3104 const maxWidth = Math.max(320, globalThis.innerWidth - viewportGap * 2);
3105 const maxHeight = Math.max(300, globalThis.innerHeight - viewportGap * 2);
3106 const width = Math.min(Math.max(320, Number(bounds.width || 320)), maxWidth);
3107 const height = Math.min(Math.max(300, Number(bounds.height || 300)), maxHeight);
3108 return {
3109 left: Math.min(
3110 Math.max(viewportGap, Number(bounds.left || viewportGap)),
3111 Math.max(viewportGap, globalThis.innerWidth - width - viewportGap),
3112 ),
3113 top: Math.min(
3114 Math.max(viewportGap, Number(bounds.top || viewportGap)),
3115 Math.max(viewportGap, globalThis.innerHeight - height - viewportGap),
3116 ),
3117 width,
3118 height,
3119 };
3120 };
3121 const setBounds = (bounds = {}) => {
3122 const next = normalizedBounds(bounds);
3123 inner.style.position = "fixed";
3124 inner.style.transform = "none";
3125 inner.style.left = `${Math.round(next.left)}px`;
3126 inner.style.top = `${Math.round(next.top)}px`;
3127 inner.style.width = `${Math.round(next.width)}px`;
3128 inner.style.height = `${Math.round(next.height)}px`;
3129 inner.style.maxWidth = `${Math.max(320, globalThis.innerWidth - viewportGap * 2)}px`;
3130 inner.style.maxHeight = `${Math.max(300, globalThis.innerHeight - viewportGap * 2)}px`;
3131 this.queueViewportSync();
3132 return next;
3133 };
3134 const focusBounds = () => ({
3135 left: viewportGap,
3136 top: viewportGap,
3137 width: globalThis.innerWidth - viewportGap * 2,
3138 height: globalThis.innerHeight - viewportGap * 2,
3139 });
3140 const clampPosition = (left, top) => {
3141 const bounds = inner.getBoundingClientRect();
3142 const maxLeft = Math.max(viewportGap, globalThis.innerWidth - bounds.width - viewportGap);
3143 const maxTop = Math.max(viewportGap, globalThis.innerHeight - bounds.height - viewportGap);
3144 return {
3145 left: Math.min(Math.max(viewportGap, left), maxLeft),
3146 top: Math.min(Math.max(viewportGap, top), maxTop),
3147 };
3148 };
3149 const clampGeometry = () => {
3150 if (inner.classList.contains("is-focus-mode")) {
3151 setBounds(focusBounds());
3152 return;
3153 }
3154 setBounds(currentBounds());
3155 };
3156 clampGeometry();
3157
3158 const newAction = globalThis.document.createElement("div");
3159 newAction.className = "browser-header-actions surface-modal-new-action";
3160 newAction.innerHTML = `
3161 <button type="button" class="browser-header-new-button surface-modal-new-button" title="New Browser" aria-label="New Browser">
3162 <x-icon aria-hidden="true" name="add"></x-icon>
3163 <span>New</span>
3164 </button>
3165 `;
3166 const newButton = newAction.querySelector(".browser-header-new-button");
3167 const onNewClick = async () => {
3168 if (!newButton || newButton.disabled || this.isBusy()) return;
3169 newButton.disabled = true;
3170 try {
3171 await this.openNewBrowser();
3172 } finally {
3173 if (globalThis.document?.contains?.(newButton)) newButton.disabled = false;
3174 }
3175 };
3176 newButton?.addEventListener("click", onNewClick);
3177 placeSurfaceModalHeaderAction(header, newAction, "new");
3178
3179 const focusButton = globalThis.document.createElement("button");
3180 focusButton.type = "button";
3181 focusButton.className = "surface-button browser-modal-focus-button";
3182 focusButton.innerHTML = '<x-icon aria-hidden="true" name="fullscreen"></x-icon>';
3183 const updateFocusButton = (active) => {
3184 const label = active ? "Restore size" : "Focus mode";
3185 focusButton.setAttribute("aria-label", label);
3186 focusButton.setAttribute("title", label);
3187 focusButton.querySelector("x-icon").name = active ? "fullscreen_exit" : "fullscreen";
3188 };
3189 const setFocusMode = (enabled) => {
3190 if (enabled) {
3191 beforeFocusBounds = currentBounds();
3192 inner.classList.add("is-focus-mode");
3193 setBounds(focusBounds());
3194 updateFocusButton(true);
3195 return;
3196 }
3197 inner.classList.remove("is-focus-mode");
3198 setBounds(beforeFocusBounds || currentBounds());
3199 beforeFocusBounds = null;
3200 updateFocusButton(false);
3201 };
3202 updateFocusButton(false);
3203 placeSurfaceModalHeaderAction(header, focusButton, "window");
3204 const onFocusClick = () => setFocusMode(!inner.classList.contains("is-focus-mode"));
3205 focusButton.addEventListener("click", onFocusClick);
3206
3207 globalThis.addEventListener("resize", clampGeometry);
3208 if (globalThis.ResizeObserver) {
3209 resizeObserver = new ResizeObserver(clampGeometry);
3210 resizeObserver.observe(inner);
3211 if (stage) {
3212 this._stageResizeObserver?.disconnect?.();
3213 this._stageResizeObserver = new ResizeObserver(() => {
3214 this.queueViewportSync();
3215 });
3216 this._stageResizeObserver.observe(stage);
3217 }
3218 }
3219 const surfaceSequence = this._surfaceOpenSequence;
3220 globalThis.requestAnimationFrame(() => {
3221 if (!this.isCurrentSurfaceOpen(surfaceSequence)) return;
3222 this.queueViewportSync(true);
3223 });
3224
3225 const onPointerMove = (event) => {
3226 if (!drag) return;
3227 const next = clampPosition(
3228 drag.left + event.clientX - drag.x,
3229 drag.top + event.clientY - drag.y,
3230 );
3231 inner.style.left = `${next.left}px`;
3232 inner.style.top = `${next.top}px`;
3233 clampGeometry();
3234 };
3235 const onPointerUp = () => {
3236 drag = null;
3237 globalThis.removeEventListener("pointermove", onPointerMove);
3238 globalThis.removeEventListener("pointerup", onPointerUp);
3239 try {
3240 header.releasePointerCapture?.(header.__browserPanelPointerId || 0);
3241 } catch {}
3242 };
3243 const onPointerDown = (event) => {
3244 if (event.button !== 0) return;
3245 if (event.target?.closest?.("button, input, select, textarea, a")) return;
3246 if (inner.classList.contains("is-focus-mode")) return;
3247 const current = inner.getBoundingClientRect();
3248 drag = {
3249 x: event.clientX,
3250 y: event.clientY,
3251 left: current.left,
3252 top: current.top,
3253 };
3254 header.__browserPanelPointerId = event.pointerId;
3255 header.setPointerCapture?.(event.pointerId);
3256 globalThis.addEventListener("pointermove", onPointerMove);
3257 globalThis.addEventListener("pointerup", onPointerUp);
3258 event.preventDefault();
3259 };
3260 header.addEventListener("pointerdown", onPointerDown);
3261
3262 this._floatingCleanup = () => {
3263 newButton?.removeEventListener("click", onNewClick);
3264 newAction.remove();
3265 focusButton.removeEventListener("click", onFocusClick);
3266 focusButton.remove();
3267 header.removeEventListener("pointerdown", onPointerDown);
3268 globalThis.removeEventListener("pointermove", onPointerMove);
3269 globalThis.removeEventListener("pointerup", onPointerUp);
3270 globalThis.removeEventListener("resize", clampGeometry);
3271 resizeObserver?.disconnect?.();
3272 this._stageResizeObserver?.disconnect?.();
3273 this._stageResizeObserver = null;
3274 inner.classList.remove("is-focus-mode");
3275 };
3276 },
3277
3278 setupCanvasSurface(element = null) {
3279 const surfaceSequence = this._surfaceOpenSequence;
3280 this._floatingCleanup?.();
3281 this._floatingCleanup = null;
3282 this._stageResizeObserver?.disconnect?.();
3283 const root = element || globalThis.document?.querySelector(".browser-panel");
3284 const stage = root?.querySelector?.(".browser-stage");
3285 this._stageElement = stage || null;
3286 if (stage && globalThis.ResizeObserver) {
3287 this._stageResizeObserver = new ResizeObserver(() => {
3288 this.queueViewportSync();
3289 });
3290 this._stageResizeObserver.observe(stage);
3291 }
3292 globalThis.requestAnimationFrame?.(() => {
3293 if (!this.isCurrentSurfaceOpen(surfaceSequence) || this._mode !== "canvas") return;
3294 this.queueViewportSync(true);
3295 });
3296 },
3297
3298 get activeTitle() {
3299 return this.frameState?.title || "Browser";
3300 },
3301
3302 get activeUrl() {
3303 return this.frameState?.currentUrl || this.address || "about:blank";
3304 },
3305
3306 };
3307
3308 export const store = createStore("browserPage", model);
3309
3310 const WEB_INTENT_SCHEMES = new Set(["http", "https", "file", "about"]);
3311
3312 function isWebUrlIntent(url = "") {
3313 const value = String(url || "").trim();
3314 if (!value) return true;
3315 const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value);
3316 if (!scheme) return true;
3317 return WEB_INTENT_SCHEMES.has(scheme[1].toLowerCase());
3318 }
3319
3320 registerUrlHandler(async (intent = {}) => {
3321 const url = String(intent.url || "").trim();
3322 // Custom schemes such as a0-editor: belong to other surfaces; claiming them
3323 // here would navigate the browser to an unloadable URL.
3324 if (!isWebUrlIntent(url)) return false;
3325 const payload = { url, source: intent.source || "surface-url-intent" };
3326 await openLatestSurface("browser", payload);
3327 return await store.openUrlIntent(url, { source: payload.source });
3328 });