Improve browser session context lifecycle

Keep browser sessions context-qualified so tabs from different chats can coexist without closing on context switches. Create a real chat context when Browser launches from dashboard/no selected context, preserving agent handoff for that session. Move chat context detail out of visible tab labels and into hover tooltips using only real chat names, with regression coverage for the updated lifecycle.

Alessandro committed Apr 28, 2026 at 14:40 UTC b7ba8eff7f883c4100bbf1ef726b4981ff8dc365
8 files changed +566 -108
plugins/_browser/api/ws_browser.py
+30 -4
@@ -8,7 +8,7 @@ from typing import Any, ClassVar
8 from agent import AgentContext
9 from helpers.ws import WsHandler
10 from helpers.ws_manager import WsResult
11 -from plugins._browser.helpers.runtime import get_runtime
11 +from plugins._browser.helpers.runtime import get_runtime, list_runtime_sessions
12
13
14 FRAME_IDLE_POLL_SECONDS = 0.05
@@ -38,6 +38,8 @@ class WsBrowser(WsHandler):
38 return await self._subscribe(data, sid)
39 if event == "browser_viewer_unsubscribe":
40 return self._unsubscribe(data, sid)
41 + if event == "browser_viewer_sessions":
42 + return await self._sessions(data)
43 if event == "browser_viewer_command":
44 return await self._command(data, sid)
45 if event == "browser_viewer_input":
@@ -90,8 +92,10 @@ class WsBrowser(WsHandler):
92
93 return {
94 "context_id": context_id,
95 + "active_browser_context_id": context_id,
96 "active_browser_id": active_id,
94 - "browsers": browsers,
97 + "browsers": await self._all_browser_tabs(),
98 + "all_browsers": True,
99 "viewer_id": viewer_id,
100 }
101
@@ -104,6 +108,13 @@ class WsBrowser(WsHandler):
108 task.cancel()
109 return {"context_id": context_id, "unsubscribed": True}
110
111 + async def _sessions(self, data: dict[str, Any]) -> dict[str, Any]:
112 + return {
113 + "context_id": self._context_id(data),
114 + "browsers": await self._all_browser_tabs(),
115 + "all_browsers": True,
116 + }
117 +
118 async def _command(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult:
119 context_id = self._context_id(data)
120 if not context_id:
@@ -136,17 +147,20 @@ class WsBrowser(WsHandler):
147 listing = await runtime.call("list")
148 last_interacted_browser_id = listing.get("last_interacted_browser_id")
149 snapshot = await self._snapshot_for_result(runtime, result)
150 + all_browsers = await self._all_browser_tabs()
151 await self.emit_to(
152 sid,
153 "browser_viewer_state",
154 {
155 "context_id": context_id,
156 + "active_browser_context_id": context_id,
157 "viewer_id": viewer_id,
158 "command": command,
159 "browser_id": browser_id,
160 "result": result,
161 "snapshot": snapshot,
149 - "browsers": listing.get("browsers") or [],
162 + "browsers": all_browsers,
163 + "all_browsers": True,
164 "last_interacted_browser_id": last_interacted_browser_id,
165 },
166 correlation_id=data.get("correlationId"),
@@ -154,7 +168,9 @@ class WsBrowser(WsHandler):
168 return {
169 "result": result,
170 "snapshot": snapshot,
157 - "browsers": listing.get("browsers") or [],
171 + "browsers": all_browsers,
172 + "all_browsers": True,
173 + "active_browser_context_id": context_id,
174 "last_interacted_browser_id": last_interacted_browser_id,
175 "command": command,
176 "browser_id": browser_id,
@@ -255,6 +271,16 @@ class WsBrowser(WsHandler):
271 return await runtime.call("screenshot", browser_id, quality=SCREENCAST_QUALITY)
272 return None
273
274 + async def _all_browser_tabs(self) -> list[dict[str, Any]]:
275 + browsers: list[dict[str, Any]] = []
276 + for session in await list_runtime_sessions():
277 + context_id = str(session.get("context_id") or "")
278 + for browser in session.get("browsers") or []:
279 + entry = dict(browser or {})
280 + entry.setdefault("context_id", context_id)
281 + browsers.append(entry)
282 + return browsers
283 +
284 async def _stream_frames(
285 self,
286 sid: str,
plugins/_browser/extensions/webui/get_tool_message_handler/browser-tool-handler.js
+20 -2
@@ -69,6 +69,16 @@ function browserIdFromResult(result = {}, kvps = {}) {
69 );
70 }
71
72 +function browserContextIdFromResult(result = {}, kvps = {}) {
73 + return (
74 + result.context_id
75 + || result.state?.context_id
76 + || kvps.context_id
77 + || kvps.contextId
78 + || null
79 + );
80 +}
81 +
82 function isFreshToolMessage(timestamp) {
83 const value = Number(timestamp);
84 if (!Number.isFinite(value) || value <= 0) return true;
@@ -94,7 +104,11 @@ function autoOpenBrowserCanvas(args, result) {
104 sessionStorage.setItem(persistedKey, "1");
105 requestAnimationFrame(async () => {
106 if (!(await browserAllowsToolAutofocus())) return;
97 - void openBrowserCanvas({ browserId, source: "tool" });
107 + void openBrowserCanvas({
108 + browserId,
109 + contextId: browserContextIdFromResult(result, kvps),
110 + source: "tool",
111 + });
112 });
113 }
114
@@ -119,7 +133,11 @@ function drawBrowserTool({
133 const browserButton = createActionButton(
134 "visibility",
135 "Browser",
122 - () => openBrowserCanvas({ browserId: browserIdFromResult(browserResult, kvps), source: "tool" }),
136 + () => openBrowserCanvas({
137 + browserId: browserIdFromResult(browserResult, kvps),
138 + contextId: browserContextIdFromResult(browserResult, kvps),
139 + source: "tool",
140 + }),
141 );
142 browserButton.setAttribute("title", "Open Browser");
143 browserButton.setAttribute("aria-label", "Open Browser");
plugins/_browser/extensions/webui/right_canvas_register_surfaces/register-browser.js
+1
@@ -93,6 +93,7 @@ export default async function registerBrowserSurface(canvas) {
93 await browser.onOpen(panel, {
94 mode: "canvas",
95 browserId: payload.browserId || payload.browser_id || null,
96 + contextId: payload.contextId || payload.context_id || null,
97 });
98 }
99 },
plugins/_browser/extensions/webui/set_messages_after_loop/auto-open-browser-results.js
+12 -1
@@ -16,6 +16,7 @@ export default async function autoOpenBrowserResults(context) {
16 if (!shouldAutoOpen(args, payload, result)) continue;
17
18 const browserId = getBrowserId(payload, result);
19 + const contextId = getBrowserContextId(payload, result);
20 const key = [
21 args?.id || "",
22 browserId || "",
@@ -26,7 +27,7 @@ export default async function autoOpenBrowserResults(context) {
27
28 requestAnimationFrame(async () => {
29 if (!(await browserAllowsToolAutofocus())) return;
29 - void openBrowserCanvas({ browserId, source: "tool-result" });
30 + void openBrowserCanvas({ browserId, contextId, source: "tool-result" });
31 });
32 }
33 }
@@ -105,6 +106,16 @@ function getBrowserId(payload = {}, result = {}) {
106 );
107 }
108
109 +function getBrowserContextId(payload = {}, result = {}) {
110 + return (
111 + result.context_id
112 + || result.state?.context_id
113 + || payload.context_id
114 + || payload.contextId
115 + || null
116 + );
117 +}
118 +
119 function isFresh(timestamp, fallbackTimestamp) {
120 const messageMs = toMs(timestamp) || toMs(fallbackTimestamp);
121 if (!messageMs) return true;
plugins/_browser/helpers/runtime.py
+22
@@ -916,6 +916,7 @@ class _BrowserRuntimeCore:
916 history_length = 0
917 return {
918 "id": browser_page.id,
919 + "context_id": self.context_id,
920 "currentUrl": page.url,
921 "title": title,
922 "canGoBack": bool(history_length and int(history_length) > 1),
@@ -1061,4 +1062,25 @@ def known_context_ids() -> list[str]:
1062 return sorted(_runtimes)
1063
1064
1065 +async def list_runtime_sessions() -> list[dict[str, Any]]:
1066 + with _runtime_lock:
1067 + runtimes = list(_runtimes.items())
1068 +
1069 + sessions: list[dict[str, Any]] = []
1070 + for context_id, runtime in runtimes:
1071 + try:
1072 + listing = await runtime.call("list")
1073 + except Exception as exc:
1074 + PrintStyle.warning(f"Browser runtime list failed for context {context_id}: {exc}")
1075 + continue
1076 + sessions.append(
1077 + {
1078 + "context_id": context_id,
1079 + "browsers": listing.get("browsers") or [],
1080 + "last_interacted_browser_id": listing.get("last_interacted_browser_id"),
1081 + }
1082 + )
1083 + return sessions
1084 +
1085 +
1086 atexit.register(close_all_runtimes_sync)
plugins/_browser/webui/browser-panel.html
+6 -5
@@ -11,25 +11,26 @@
11 <div x-data>
12 <template x-if="$store.browserPage">
13 <div class="browser-panel" x-create="$store.browserPage.onOpen($el, xAttrs($el) || {})" x-destroy="$store.browserPage.cleanup()"
14 + x-effect="$store.browserPage.handleSelectedContextChange($store.chats?.selected)"
15 @keydown.window="$store.browserPage.handleKeydown($event)">
16 <div class="browser-meta">
17 <div class="browser-meta-top">
18 <div class="browser-session-tabs" role="tablist" aria-label="Browser sessions">
18 - <template x-for="browser in $store.browserPage.browsers" :key="browser.id">
19 + <template x-for="browser in $store.browserPage.browsers" :key="$store.browserPage.browserTabKey(browser)">
20 <div class="browser-tab-shell" :class="{ 'is-active': $store.browserPage.isActiveBrowser(browser) }">
21 <button type="button" class="browser-tab" role="tab"
22 :aria-selected="$store.browserPage.isActiveBrowser(browser).toString()"
22 - :title="$store.browserPage.browserTabLabel(browser)"
23 - @click="$store.browserPage.selectBrowser(browser.id)">
23 + :title="$store.browserPage.browserTabTooltip(browser)"
24 + @click="$store.browserPage.selectBrowser(browser.id, browser.context_id)">
25 <span class="material-symbols-outlined browser-tab-icon" aria-hidden="true">language</span>
26 <span class="browser-tab-title" x-text="$store.browserPage.browserTabTitle(browser)"></span>
27 </button>
28 <button type="button" class="browser-tab-close"
29 :title="'Close ' + $store.browserPage.browserTabLabel(browser)"
30 :aria-label="'Close ' + $store.browserPage.browserTabLabel(browser)"
30 - :disabled="$store.browserPage.isClosingBrowser(browser.id)"
31 + :disabled="$store.browserPage.isClosingBrowser(browser.id, browser.context_id)"
32 @pointerdown.stop
32 - @click.stop="$store.browserPage.closeBrowser(browser.id)">
33 + @click.stop="$store.browserPage.closeBrowser(browser.id, browser.context_id)">
34 <span class="material-symbols-outlined">close</span>
35 </button>
36 </div>
plugins/_browser/webui/browser-store.js
+337 -90
@@ -95,6 +95,7 @@ const model = {
95 contextId: "",
96 browsers: [],
97 activeBrowserId: null,
98 + activeBrowserContextId: "",
99 address: "",
100 frameSrc: "",
101 frameState: null,
@@ -142,6 +143,8 @@ const model = {
143 _connectSequence: 0,
144 _viewerToken: "",
145 _contextCreatePromise: null,
146 + _lastSelectedContextId: "",
147 + _sessionRefreshPromise: null,
148 extensionMenuOpen: false,
149 extensionInstallUrl: "",
150 extensionActionLoading: false,
@@ -173,7 +176,7 @@ const model = {
176 try {
177 const response = await callJsonApi("/plugins/_browser/extensions", {
178 action: "list",
176 - context_id: this.contextId,
179 + context_id: this.resolveContextId() || this.contextId,
180 });
181 if (!response?.ok) {
182 throw new Error(response?.error || "Could not load browser extensions.");
@@ -210,7 +213,7 @@ const model = {
213 this._configRefreshPromise = (async () => {
214 const response = await callJsonApi("/plugins/_browser/extensions", {
215 action: "list",
213 - context_id: this.contextId || this.resolveContextId(),
216 + context_id: this.resolveContextId() || this.contextId,
217 });
218 if (!response?.ok) {
219 throw new Error(response?.error || "Could not load browser settings.");
@@ -233,6 +236,39 @@ const model = {
236 return this.autofocusActivePage !== false;
237 },
238
239 + handleSelectedContextChange(contextId = "") {
240 + const selectedContextId = this.normalizeContextId(contextId || this.resolveContextId());
241 + if (selectedContextId === this._lastSelectedContextId) return;
242 + this._lastSelectedContextId = selectedContextId;
243 + if (!this._surfaceMounted) return;
244 + void this.refreshBrowserSessions(selectedContextId);
245 + },
246 +
247 + async refreshBrowserSessions(contextId = "") {
248 + if (this._sessionRefreshPromise) {
249 + await this._sessionRefreshPromise;
250 + return;
251 + }
252 + this._sessionRefreshPromise = (async () => {
253 + const response = await websocket.request(
254 + "browser_viewer_sessions",
255 + { context_id: this.normalizeContextId(contextId || this.resolveContextId()) },
256 + { timeoutMs: 10000 },
257 + );
258 + const data = firstOk(response);
259 + this.applyBrowserListing(data.browsers || [], data.context_id || "", {
260 + replaceAll: Boolean(data.all_browsers),
261 + });
262 + })();
263 + try {
264 + await this._sessionRefreshPromise;
265 + } catch (error) {
266 + console.warn("Browser session refresh failed", error);
267 + } finally {
268 + this._sessionRefreshPromise = null;
269 + }
270 + },
271 +
272 toggleExtensionsMenu() {
273 this.extensionMenuOpen = !this.extensionMenuOpen;
274 if (this.extensionMenuOpen) {
@@ -251,6 +287,10 @@ const model = {
287 return getContext() || urlContext || chatsStore.selected || "";
288 },
289
290 + normalizeContextId(contextId = "") {
291 + return String(contextId || "").trim();
292 + },
293 +
294 async ensureContextId() {
295 const existingContextId = String(this.resolveContextId() || "").trim();
296 if (existingContextId) {
@@ -263,13 +303,24 @@ const model = {
303 }
304
305 try {
266 - this.contextId = await this._contextCreatePromise;
267 - return this.contextId;
306 + const contextId = await this._contextCreatePromise;
307 + this.contextId = contextId;
308 + return contextId;
309 } finally {
310 this._contextCreatePromise = null;
311 }
312 },
313
314 + async contextIdForNewBrowser() {
315 + return await this.ensureContextId();
316 + },
317 +
318 + async contextIdForActiveBrowser() {
319 + const activeContextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
320 + if (activeContextId) return activeContextId;
321 + return await this.ensureContextId();
322 + },
323 +
324 async createChatContextForBrowser() {
325 const response = await callJsonApi("/chat_create", {
326 current_context: this.resolveContextId() || "",
@@ -361,7 +412,7 @@ const model = {
412 try {
413 const response = await callJsonApi("/plugins/_browser/extensions", {
414 action: "install_web_store",
364 - context_id: this.contextId,
415 + context_id: this.resolveContextId() || this.contextId,
416 url,
417 });
418 if (!response?.ok) {
@@ -388,7 +439,7 @@ const model = {
439 try {
440 const response = await callJsonApi("/plugins/_browser/extensions", {
441 action: "set_extension_enabled",
391 - context_id: this.contextId,
442 + context_id: this.resolveContextId() || this.contextId,
443 path,
444 enabled: Boolean(enabled),
445 });
@@ -415,7 +466,7 @@ const model = {
466 try {
467 const response = await callJsonApi("/plugins/_browser/extensions", {
468 action: "set_model_preset",
418 - context_id: this.contextId,
469 + context_id: this.resolveContextId() || this.contextId,
470 model_preset: presetName,
471 });
472 if (!response?.ok) {
@@ -463,17 +514,21 @@ const model = {
514 const requestedBrowserId = this.normalizeBrowserId(
515 options.requestedBrowserId ?? options.browserId ?? options.browser_id,
516 );
517 + const requestedContextId = this.normalizeContextId(
518 + options.requestedContextId ?? options.contextId ?? options.context_id,
519 + );
520 const nextMode = options?.mode === "modal" ? "modal" : "canvas";
521 if (nextMode === "canvas" && !this.isCanvasSurfaceVisible(element)) {
522 return;
523 }
470 - const openSignature = this.surfaceOpenSignature(element, nextMode, requestedBrowserId);
524 + const openSignature = this.surfaceOpenSignature(element, nextMode, requestedBrowserId, requestedContextId);
525 if (this._openPromise && this._openSignature === openSignature) {
526 return await this._openPromise;
527 }
528 const promise = this.openSurface(element, {
529 ...options,
530 requestedBrowserId,
531 + requestedContextId,
532 nextMode,
533 });
534 this._openPromise = promise;
@@ -494,6 +549,10 @@ const model = {
549 const requestedBrowserId = this.normalizeBrowserId(
550 options.requestedBrowserId ?? options.browserId ?? options.browser_id,
551 );
552 + const requestedContextId = this.normalizeContextId(
553 + options.requestedContextId ?? options.contextId ?? options.context_id,
554 + );
555 + let targetContextId = requestedContextId;
556 const nextMode = options?.nextMode || (options?.mode === "modal" ? "modal" : "canvas");
557 if (nextMode === "canvas" && !this.isCanvasSurfaceVisible(element)) {
558 this.loading = false;
@@ -501,22 +560,28 @@ const model = {
560 }
561 const surfaceSequence = this._surfaceOpenSequence + 1;
562 this._surfaceOpenSequence = surfaceSequence;
504 - this.prepareSurfaceOpen(nextMode, requestedBrowserId);
563 + this.prepareSurfaceOpen(nextMode, requestedBrowserId, requestedContextId);
564 if (nextMode === "modal") {
565 this.setupFloatingModal(element);
566 } else {
567 this.setupCanvasSurface(element);
568 }
569 try {
511 - await this.ensureContextId();
570 + if (!targetContextId && !this.activeBrowserContextId && !this.contextId) {
571 + targetContextId = await this.ensureContextId();
572 + }
573 if (!this.isCurrentSurfaceOpen(surfaceSequence)) return;
574 await this.refreshStatus();
575 if (!this.isCurrentSurfaceOpen(surfaceSequence)) return;
576 const viewport = await this.waitForSurfaceViewport({ sequence: surfaceSequence });
577 if (!this.isCurrentSurfaceOpen(surfaceSequence)) return;
578 if (nextMode === "canvas" && !viewport) return;
518 - this.resetRenderedFrameIfViewportChanged(viewport, requestedBrowserId);
519 - await this.connectViewer({ browserId: requestedBrowserId, initialViewport: viewport });
579 + this.resetRenderedFrameIfViewportChanged(viewport, requestedBrowserId, targetContextId);
580 + await this.connectViewer({
581 + browserId: requestedBrowserId,
582 + contextId: targetContextId,
583 + initialViewport: viewport,
584 + });
585 if (!this.isCurrentSurfaceOpen(surfaceSequence)) return;
586 await this.syncViewportAfterSurfaceOpen(surfaceSequence);
587 } catch (error) {
@@ -534,13 +599,14 @@ const model = {
599 }
600 },
601
537 - surfaceOpenSignature(element = null, mode = "", browserId = null) {
602 + surfaceOpenSignature(element = null, mode = "", browserId = null, contextId = "") {
603 const root = element || globalThis.document?.querySelector(".browser-panel");
604 if (root && !root.__browserSurfaceOpenId) {
605 root.__browserSurfaceOpenId = makeViewerToken();
606 }
607 return [
608 mode || "",
609 + this.normalizeContextId(contextId) || "",
610 this.normalizeBrowserId(browserId) || "",
611 root?.__browserSurfaceOpenId || "",
612 ].join(":");
@@ -600,10 +666,10 @@ const model = {
666 return Boolean(rect && Math.round(rect.width || 0) >= 80 && Math.round(rect.height || 0) >= 80);
667 },
668
603 - prepareSurfaceOpen(nextMode, requestedBrowserId = null) {
669 + prepareSurfaceOpen(nextMode, requestedBrowserId = null, requestedContextId = "") {
670 const previousMode = this._mode;
671 const modeChanged = this._surfaceMounted && previousMode && previousMode !== nextMode;
606 - const targetBrowserId = requestedBrowserId || this.activeBrowserId || this.firstBrowserId();
672 + const targetBrowserId = requestedBrowserId || this.activeBrowserId || this.firstBrowserId(requestedContextId);
673 this._mode = nextMode;
674 this._surfaceMounted = true;
675 this._surfaceOpenedAt = Date.now();
@@ -628,10 +694,11 @@ const model = {
694 this._lastFrameAt = 0;
695 },
696
631 - resetRenderedFrameIfViewportChanged(viewport = null, requestedBrowserId = null) {
697 + resetRenderedFrameIfViewportChanged(viewport = null, requestedBrowserId = null, requestedContextId = "") {
698 if (!viewport || !this.frameSrc || !this._lastViewport) return;
699 const targetBrowserId = requestedBrowserId || this.activeBrowserId || this.firstBrowserId();
634 - if (!this.sameBrowserId(this._lastViewport.browserId, targetBrowserId)) return;
700 + const targetContextId = this.normalizeContextId(requestedContextId || this.contextIdForBrowserId(targetBrowserId) || this.activeBrowserContextId);
701 + if (!this.sameBrowserTab(this._lastViewport.browserId, this._lastViewport.contextId, targetBrowserId, targetContextId)) return;
702 const changed = Math.abs(this._lastViewport.width - viewport.width) > VIEWPORT_SYNC_SIZE_TOLERANCE
703 || Math.abs(this._lastViewport.height - viewport.height) > VIEWPORT_SYNC_SIZE_TOLERANCE;
704 if (!changed) return;
@@ -695,8 +762,16 @@ const model = {
762
763 async connectViewer(options = {}) {
764 let contextId = "";
765 + const requestedBrowserId = this.normalizeBrowserId(options.browserId ?? this.activeBrowserId);
766 + const requestedContextId = this.normalizeContextId(
767 + options.contextId
768 + ?? options.context_id
769 + ?? this.contextIdForBrowserId(requestedBrowserId)
770 + ?? this.activeBrowserContextId
771 + ?? this.contextId
772 + );
773 try {
699 - contextId = await this.ensureContextId();
774 + contextId = requestedContextId || await this.ensureContextId();
775 } catch (error) {
776 this.connected = false;
777 this.switchingBrowserId = null;
@@ -710,7 +785,13 @@ const model = {
785 this._surfaceSwitching = false;
786 return;
787 }
713 - const requestedBrowserId = this.normalizeBrowserId(options.browserId ?? this.activeBrowserId);
788 + const previousContextId = this.normalizeContextId(this.contextId);
789 + if (previousContextId && previousContextId !== contextId) {
790 + try {
791 + await websocket.emit("browser_viewer_unsubscribe", { context_id: previousContextId });
792 + } catch {}
793 + }
794 + this.contextId = contextId;
795 const sequence = this._connectSequence + 1;
796 const viewerToken = makeViewerToken();
797 this._connectSequence = sequence;
@@ -726,7 +807,7 @@ const model = {
807 response = await websocket.request(
808 "browser_viewer_subscribe",
809 {
729 - context_id: this.contextId,
810 + context_id: contextId,
811 browser_id: requestedBrowserId,
812 viewer_id: viewerToken,
813 viewport_width: initialViewport?.width,
@@ -750,8 +831,11 @@ const model = {
831 return;
832 }
833 const data = firstOk(response);
753 - this.browsers = data.browsers || [];
754 - this.setActiveBrowserId(data.active_browser_id || requestedBrowserId || this.activeBrowserId || null);
834 + this.applyBrowserListing(data.browsers || [], contextId, { replaceAll: Boolean(data.all_browsers) });
835 + this.setActiveBrowserId(
836 + data.active_browser_id || requestedBrowserId || this.activeBrowserId || null,
837 + data.active_browser_context_id || contextId,
838 + );
839 this.connected = true;
840 this.browserInstallExpected = false;
841 },
@@ -761,12 +845,17 @@ const model = {
845 const frameHandler = ({ data }) => {
846 if (data?.context_id !== this.contextId) return;
847 if (data?.viewer_id && data.viewer_id !== this._viewerToken) return;
848 + const incomingContextId = this.normalizeContextId(data.context_id || this.contextId);
849 const incomingBrowserId = this.normalizeBrowserId(data.browser_id || data.state?.id);
765 - this.browsers = data.browsers || this.browsers;
850 + this.applyBrowserListing(data.browsers || [], incomingContextId, { replaceContext: true });
851 if (incomingBrowserId && !this.activeBrowserId) {
767 - this.setActiveBrowserId(incomingBrowserId);
852 + this.setActiveBrowserId(incomingBrowserId, incomingContextId);
853 }
769 - if (incomingBrowserId && this.activeBrowserId && !this.sameBrowserId(incomingBrowserId, this.activeBrowserId)) {
854 + if (
855 + incomingBrowserId
856 + && this.activeBrowserId
857 + && !this.sameBrowserTab(incomingBrowserId, incomingContextId, this.activeBrowserId, this.activeBrowserContextId)
858 + ) {
859 return;
860 }
861 if (data.state) {
@@ -779,8 +868,12 @@ const model = {
868 const frameBrowserId = incomingBrowserId || this.activeBrowserId;
869 this.queueFrameRender(`data:${data.mime || "image/jpeg"};base64,${data.image}`, {
870 browserId: frameBrowserId,
871 + contextId: incomingContextId,
872 onAccepted: () => {
783 - if (this.sameBrowserId(this.switchingBrowserId, frameBrowserId)) {
873 + if (
874 + this.sameBrowserId(this.switchingBrowserId, frameBrowserId)
875 + && this.normalizeContextId(this.activeBrowserContextId) === incomingContextId
876 + ) {
877 this.switchingBrowserId = null;
878 }
879 this._surfaceSwitching = false;
@@ -794,7 +887,7 @@ const model = {
887 }
888 if (!data.image && !data.state) {
889 if (!this.activeBrowserId) {
797 - this.setActiveBrowserId(null);
890 + this.setActiveBrowserId(null, "");
891 this.frameState = null;
892 this.frameSrc = "";
893 }
@@ -808,27 +901,33 @@ const model = {
901 const stateHandler = ({ data }) => {
902 if (data?.context_id !== this.contextId) return;
903 if (data?.viewer_id && data.viewer_id !== this._viewerToken) return;
811 - this.browsers = data.browsers || [];
904 + const commandContextId = this.normalizeContextId(data.active_browser_context_id || data.context_id || this.contextId);
905 + this.applyBrowserListing(data.browsers || [], commandContextId, { replaceAll: Boolean(data.all_browsers) });
906 const command = String(data.command || "").toLowerCase();
907 const commandBrowserId = this.normalizeBrowserId(data.browser_id);
908 const result = data.result || {};
909 const resultState = this.stateFromCommandResult(result);
910 + const resultContextId = this.normalizeContextId(
911 + result.context_id
912 + || result.state?.context_id
913 + || commandContextId
914 + );
915 const preferredBrowserId = this.normalizeBrowserId(
916 result.id
917 || result.state?.id
918 || data.last_interacted_browser_id
919 || this.activeBrowserId
821 - || this.firstBrowserId()
920 + || this.firstBrowserId(resultContextId)
921 );
922 if (
923 !this.activeBrowserId
924 || command === "open"
925 || command === "close"
827 - || this.sameBrowserId(commandBrowserId, this.activeBrowserId)
926 + || this.sameBrowserTab(commandBrowserId, commandContextId, this.activeBrowserId, this.activeBrowserContextId)
927 ) {
829 - this.setActiveBrowserId(preferredBrowserId);
928 + this.setActiveBrowserId(preferredBrowserId, resultContextId);
929 }
831 - this.applyActiveFrameState(resultState || this.browserById(this.activeBrowserId));
930 + this.applyActiveFrameState(resultState || this.browserById(this.activeBrowserId, this.activeBrowserContextId));
931 this.applySnapshot(data.snapshot);
932 };
933 await websocket.on("browser_viewer_state", stateHandler);
@@ -989,31 +1088,49 @@ const model = {
1088 this.annotationError = "";
1089 this.beginCommand();
1090 const previousActiveBrowserId = this.activeBrowserId;
1091 + const previousActiveContextId = this.activeBrowserContextId;
1092 const commandName = String(command || "").toLowerCase();
1093 try {
994 - await this.ensureContextId();
1094 + const targetContextId = commandName === "open"
1095 + ? await this.contextIdForNewBrowser()
1096 + : this.normalizeContextId(extra.context_id || extra.contextId) || await this.contextIdForActiveBrowser();
1097 + const targetBrowserId = this.normalizeBrowserId(extra.browser_id ?? this.activeBrowserId);
1098 + this.contextId = targetContextId;
1099 const response = await websocket.request(
1100 "browser_viewer_command",
1101 {
998 - context_id: this.contextId,
999 - browser_id: this.activeBrowserId,
1102 + ...extra,
1103 + context_id: targetContextId,
1104 + browser_id: targetBrowserId,
1105 viewer_id: this._viewerToken,
1106 command,
1002 - ...extra,
1107 },
1108 { timeoutMs: 20000 },
1109 );
1110 const data = firstOk(response);
1007 - this.browsers = data.browsers || this.browsers;
1111 + this.applyBrowserListing(data.browsers || [], targetContextId, { replaceAll: Boolean(data.all_browsers) });
1112 const result = data.result || {};
1009 - this.setActiveBrowserId(
1113 + const resultContextId = this.normalizeContextId(
1114 + result.context_id
1115 + || result.state?.context_id
1116 + || data.active_browser_context_id
1117 + || targetContextId
1118 + );
1119 + const preferredBrowser = this.browserById(
1120 result.id
1121 || result.state?.id
1122 || result.last_interacted_browser_id
1013 - || data.last_interacted_browser_id
1014 - || this.firstBrowserId()
1123 + || data.last_interacted_browser_id,
1124 + resultContextId,
1125 + )
1126 + || this.browserById(this.activeBrowserId, this.activeBrowserContextId)
1127 + || this.firstBrowser(resultContextId)
1128 + || this.firstBrowser();
1129 + this.setActiveBrowserId(preferredBrowser?.id || null, preferredBrowser?.context_id || resultContextId);
1130 + this.applyActiveFrameState(
1131 + this.stateFromCommandResult(result)
1132 + || this.browserById(this.activeBrowserId, this.activeBrowserContextId)
1133 );
1016 - this.applyActiveFrameState(this.stateFromCommandResult(result) || this.browserById(this.activeBrowserId));
1134 if (!this.activeBrowserId) {
1135 this.frameState = null;
1136 this.frameSrc = "";
@@ -1023,12 +1140,21 @@ const model = {
1140 }
1141 this.applySnapshot(data.snapshot);
1142 if (["navigate", "back", "forward", "reload", "close"].includes(commandName)) {
1026 - this.clearAnnotationsForBrowser(previousActiveBrowserId);
1143 + this.clearAnnotationsForBrowser(previousActiveBrowserId, null, previousActiveContextId);
1144 this.cancelAnnotationDraft();
1145 }
1029 - const activeChanged = this.activeBrowserId && this.activeBrowserId !== previousActiveBrowserId;
1146 + const activeChanged = this.activeBrowserId
1147 + && !this.sameBrowserTab(
1148 + this.activeBrowserId,
1149 + this.activeBrowserContextId,
1150 + previousActiveBrowserId,
1151 + previousActiveContextId,
1152 + );
1153 if ((commandName === "open" || commandName === "close" || activeChanged) && this.contextId && this.activeBrowserId) {
1031 - await this.connectViewer({ browserId: this.activeBrowserId });
1154 + await this.connectViewer({
1155 + browserId: this.activeBrowserId,
1156 + contextId: this.activeBrowserContextId,
1157 + });
1158 } else if (["navigate", "back", "forward", "reload"].includes(commandName)) {
1159 await this.restartCanvasStreamAfterPageChange();
1160 }
@@ -1074,16 +1200,21 @@ const model = {
1200 }
1201 },
1202
1077 - async selectBrowser(id) {
1203 + async selectBrowser(id, contextId = "") {
1204 const targetId = this.normalizeBrowserId(id);
1205 + const targetContextId = this.normalizeContextId(contextId || this.contextIdForBrowserId(targetId));
1206 if (!targetId) {
1207 await this.openNewBrowser();
1208 return;
1209 }
1083 - if (this.sameBrowserId(targetId, this.activeBrowserId) && this.connected && !this.isSwitchingBrowser()) {
1210 + if (
1211 + this.sameBrowserTab(targetId, targetContextId, this.activeBrowserId, this.activeBrowserContextId)
1212 + && this.connected
1213 + && !this.isSwitchingBrowser()
1214 + ) {
1215 return;
1216 }
1086 - const browser = this.browserById(targetId);
1217 + const browser = this.browserById(targetId, targetContextId);
1218 this.error = "";
1219 this.switchingBrowserId = targetId;
1220 this.cancelFrameRender();
@@ -1092,12 +1223,15 @@ const model = {
1223 if (!this.addressFocused && browser?.currentUrl) {
1224 this.address = browser.currentUrl;
1225 }
1095 - this.setActiveBrowserId(targetId);
1096 - if (this.contextId) {
1226 + this.setActiveBrowserId(targetId, targetContextId);
1227 + if (this.activeBrowserContextId) {
1228 try {
1098 - await this.connectViewer({ browserId: targetId });
1229 + await this.connectViewer({ browserId: targetId, contextId: targetContextId });
1230 } catch (error) {
1100 - if (this.sameBrowserId(this.switchingBrowserId, targetId)) {
1231 + if (
1232 + this.sameBrowserId(this.switchingBrowserId, targetId)
1233 + && this.normalizeContextId(this.activeBrowserContextId) === targetContextId
1234 + ) {
1235 this.switchingBrowserId = null;
1236 }
1237 this.error = error instanceof Error ? error.message : String(error);
@@ -1109,15 +1243,23 @@ const model = {
1243 await this.command("open");
1244 },
1245
1112 - isClosingBrowser(id) {
1246 + isClosingBrowser(id, contextId = "") {
1247 const browserId = this.normalizeBrowserId(id);
1114 - return Boolean(browserId && this._closingBrowserIds[String(browserId)]);
1248 + const key = this.browserTabKey({
1249 + id: browserId,
1250 + context_id: contextId || this.contextIdForBrowserId(browserId),
1251 + });
1252 + return Boolean(key && this._closingBrowserIds[key]);
1253 },
1254
1117 - markBrowserClosing(id, closing = true) {
1255 + markBrowserClosing(id, contextId = "", closing = true) {
1256 const browserId = this.normalizeBrowserId(id);
1257 if (!browserId) return;
1120 - const key = String(browserId);
1258 + const key = this.browserTabKey({
1259 + id: browserId,
1260 + context_id: contextId || this.contextIdForBrowserId(browserId),
1261 + });
1262 + if (!key) return;
1263 const nextClosing = { ...this._closingBrowserIds };
1264 if (closing) {
1265 nextClosing[key] = true;
@@ -1127,19 +1269,20 @@ const model = {
1269 this._closingBrowserIds = nextClosing;
1270 },
1271
1130 - async closeBrowser(id) {
1272 + async closeBrowser(id, contextId = "") {
1273 const browserId = this.normalizeBrowserId(id);
1132 - if (!browserId || this.isClosingBrowser(browserId)) return;
1133 - this.markBrowserClosing(browserId, true);
1274 + const browserContextId = this.normalizeContextId(contextId || this.contextIdForBrowserId(browserId));
1275 + if (!browserId || !browserContextId || this.isClosingBrowser(browserId, browserContextId)) return;
1276 + this.markBrowserClosing(browserId, browserContextId, true);
1277 try {
1135 - await this.command("close", { browser_id: browserId });
1278 + await this.command("close", { browser_id: browserId, context_id: browserContextId });
1279 } finally {
1137 - this.markBrowserClosing(browserId, false);
1280 + this.markBrowserClosing(browserId, browserContextId, false);
1281 }
1282 },
1283
1284 isActiveBrowser(browser) {
1142 - return Number(browser?.id) === Number(this.activeBrowserId);
1285 + return this.sameBrowserTab(browser?.id, browser?.context_id, this.activeBrowserId, this.activeBrowserContextId);
1286 },
1287
1288 browserTabTitle(browser) {
@@ -1150,12 +1293,41 @@ const model = {
1293
1294 browserTabLabel(browser) {
1295 const id = browser?.id ? `#${browser.id}` : "Browser";
1153 - return `${id} ${this.browserTabTitle(browser)}`;
1296 + return [id, this.browserTabTitle(browser)].filter(Boolean).join(" ");
1297 },
1298
1156 - firstBrowserId() {
1157 - const first = Array.isArray(this.browsers) ? this.browsers[0] : null;
1158 - return first?.id || null;
1299 + browserTabTooltip(browser) {
1300 + const chatTitle = this.browserChatTitle(browser);
1301 + return [this.browserTabLabel(browser), chatTitle ? `Chat: ${chatTitle}` : ""]
1302 + .filter(Boolean)
1303 + .join("\n");
1304 + },
1305 +
1306 + browserTabKey(browser = {}) {
1307 + const id = this.normalizeBrowserId(browser?.id ?? browser);
1308 + const contextId = this.normalizeContextId(browser?.context_id || browser?.contextId || this.activeBrowserContextId || this.contextId);
1309 + return id && contextId ? `${contextId}:${id}` : "";
1310 + },
1311 +
1312 + browserChatTitle(browser = {}) {
1313 + const contextId = this.normalizeContextId(browser?.context_id || browser?.contextId);
1314 + if (!contextId) return "";
1315 + const context = chatsStore.contexts?.find?.((item) => item?.id === contextId);
1316 + return String(context?.name || context?.title || "").trim();
1317 + },
1318 +
1319 + firstBrowser(contextId = "") {
1320 + const normalizedContextId = this.normalizeContextId(contextId);
1321 + const browsers = Array.isArray(this.browsers) ? this.browsers : [];
1322 + if (normalizedContextId) {
1323 + const scoped = browsers.find((browser) => this.normalizeContextId(browser?.context_id) === normalizedContextId);
1324 + if (scoped) return scoped;
1325 + }
1326 + return browsers[0] || null;
1327 + },
1328 +
1329 + firstBrowserId(contextId = "") {
1330 + return this.firstBrowser(contextId)?.id || null;
1331 },
1332
1333 normalizeBrowserId(id) {
@@ -1168,10 +1340,49 @@ const model = {
1340 return Boolean(leftId && rightId && leftId === rightId);
1341 },
1342
1171 - browserById(id) {
1343 + sameBrowserTab(leftId, leftContextId, rightId, rightContextId) {
1344 + return this.sameBrowserId(leftId, rightId)
1345 + && this.normalizeContextId(leftContextId) === this.normalizeContextId(rightContextId);
1346 + },
1347 +
1348 + browserById(id, contextId = "") {
1349 const numeric = this.normalizeBrowserId(id);
1350 if (!numeric || !Array.isArray(this.browsers)) return null;
1174 - return this.browsers.find((browser) => Number(browser?.id) === numeric) || null;
1351 + const normalizedContextId = this.normalizeContextId(contextId);
1352 + return this.browsers.find((browser) => (
1353 + Number(browser?.id) === numeric
1354 + && (!normalizedContextId || this.normalizeContextId(browser?.context_id) === normalizedContextId)
1355 + )) || null;
1356 + },
1357 +
1358 + contextIdForBrowserId(id) {
1359 + const numeric = this.normalizeBrowserId(id);
1360 + if (!numeric) return "";
1361 + if (this.sameBrowserId(numeric, this.activeBrowserId) && this.activeBrowserContextId) {
1362 + return this.activeBrowserContextId;
1363 + }
1364 + return this.normalizeContextId(this.browserById(numeric)?.context_id);
1365 + },
1366 +
1367 + applyBrowserListing(browsers = [], fallbackContextId = "", options = {}) {
1368 + const incoming = Array.isArray(browsers)
1369 + ? browsers.map((browser) => ({
1370 + ...browser,
1371 + context_id: this.normalizeContextId(browser?.context_id || fallbackContextId),
1372 + })).filter((browser) => browser.id && browser.context_id)
1373 + : [];
1374 + const incomingKeys = new Set(incoming.map((browser) => this.browserTabKey(browser)));
1375 + const fallback = this.normalizeContextId(fallbackContextId);
1376 + const existing = Array.isArray(this.browsers) ? this.browsers : [];
1377 + const retained = options.replaceAll
1378 + ? []
1379 + : existing.filter((browser) => {
1380 + const key = this.browserTabKey(browser);
1381 + if (incomingKeys.has(key)) return false;
1382 + if (options.replaceContext && fallback && this.normalizeContextId(browser?.context_id) === fallback) return false;
1383 + return true;
1384 + });
1385 + this.browsers = [...retained, ...incoming];
1386 },
1387
1388 stateFromCommandResult(result = {}) {
@@ -1187,7 +1398,12 @@ const model = {
1398 applyActiveFrameState(nextState = null) {
1399 if (!nextState) return;
1400 const stateId = this.normalizeBrowserId(nextState.id);
1190 - if (stateId && this.activeBrowserId && !this.sameBrowserId(stateId, this.activeBrowserId)) {
1401 + const stateContextId = this.normalizeContextId(nextState.context_id || this.activeBrowserContextId);
1402 + if (
1403 + stateId
1404 + && this.activeBrowserId
1405 + && !this.sameBrowserTab(stateId, stateContextId, this.activeBrowserId, this.activeBrowserContextId)
1406 + ) {
1407 return;
1408 }
1409 const previousUrl = String(this.frameState?.currentUrl || "");
@@ -1204,7 +1420,12 @@ const model = {
1420 applySnapshot(snapshot = null) {
1421 if (!snapshot?.image) return;
1422 const snapshotId = this.normalizeBrowserId(snapshot.browser_id || snapshot.state?.id);
1207 - if (snapshotId && this.activeBrowserId && !this.sameBrowserId(snapshotId, this.activeBrowserId)) {
1423 + const snapshotContextId = this.normalizeContextId(snapshot.context_id || snapshot.state?.context_id || this.activeBrowserContextId);
1424 + if (
1425 + snapshotId
1426 + && this.activeBrowserId
1427 + && !this.sameBrowserTab(snapshotId, snapshotContextId, this.activeBrowserId, this.activeBrowserContextId)
1428 + ) {
1429 return;
1430 }
1431 if (snapshot.state) {
@@ -1213,8 +1434,12 @@ const model = {
1434 const frameBrowserId = snapshotId || this.activeBrowserId;
1435 this.queueFrameRender(`data:${snapshot.mime || "image/jpeg"};base64,${snapshot.image}`, {
1436 browserId: frameBrowserId,
1437 + contextId: snapshotContextId,
1438 onAccepted: () => {
1217 - if (this.sameBrowserId(this.switchingBrowserId, frameBrowserId)) {
1439 + if (
1440 + this.sameBrowserId(this.switchingBrowserId, frameBrowserId)
1441 + && this.normalizeContextId(this.activeBrowserContextId) === snapshotContextId
1442 + ) {
1443 this.switchingBrowserId = null;
1444 }
1445 this._surfaceSwitching = false;
@@ -1223,19 +1448,32 @@ const model = {
1448 },
1449
1450 isSwitchingBrowser() {
1226 - return Boolean(this.switchingBrowserId && this.sameBrowserId(this.switchingBrowserId, this.activeBrowserId));
1451 + return Boolean(
1452 + this.switchingBrowserId
1453 + && this.sameBrowserId(this.switchingBrowserId, this.activeBrowserId)
1454 + && this.normalizeContextId(this.contextId) === this.normalizeContextId(this.activeBrowserContextId)
1455 + );
1456 },
1457
1458 isBusy() {
1459 return Boolean(this.loading || this.commandInFlight || this._surfaceSwitching || this.isSwitchingBrowser());
1460 },
1461
1233 - setActiveBrowserId(id) {
1462 + setActiveBrowserId(id, contextId = "") {
1463 const previous = this.activeBrowserId;
1464 + const previousContextId = this.activeBrowserContextId;
1465 const numeric = this.normalizeBrowserId(id);
1236 - const exists = !numeric || !Array.isArray(this.browsers) || this.browsers.some((browser) => Number(browser.id) === numeric);
1466 + const normalizedContextId = this.normalizeContextId(contextId || this.contextIdForBrowserId(numeric));
1467 + const exists = !numeric
1468 + || !Array.isArray(this.browsers)
1469 + || this.browsers.some((browser) => (
1470 + Number(browser.id) === numeric
1471 + && (!normalizedContextId || this.normalizeContextId(browser.context_id) === normalizedContextId)
1472 + ));
1473 this.activeBrowserId = exists ? numeric : null;
1238 - if (this.activeBrowserId !== previous) {
1474 + this.activeBrowserContextId = this.activeBrowserId ? normalizedContextId : "";
1475 + this.contextId = this.activeBrowserContextId || this.contextId;
1476 + if (this.activeBrowserId !== previous || this.activeBrowserContextId !== previousContextId) {
1477 this._lastViewportKey = "";
1478 this._lastViewport = null;
1479 this.cancelAnnotationDraft();
@@ -1338,9 +1576,10 @@ const model = {
1576
1577 visibleAnnotations() {
1578 const browserId = this.normalizeBrowserId(this.activeBrowserId);
1579 + const contextId = this.normalizeContextId(this.activeBrowserContextId);
1580 const url = this.activeAnnotationUrl();
1581 return this.annotationComments.filter((annotation) => (
1343 - this.sameBrowserId(annotation.browserId, browserId)
1582 + this.sameBrowserTab(annotation.browserId, annotation.contextId, browserId, contextId)
1583 && String(annotation.url || "") === url
1584 ));
1585 },
@@ -1350,14 +1589,15 @@ const model = {
1589 },
1590
1591 clearVisibleAnnotations() {
1353 - this.clearAnnotationsForBrowser(this.activeBrowserId, this.activeAnnotationUrl());
1592 + this.clearAnnotationsForBrowser(this.activeBrowserId, this.activeAnnotationUrl(), this.activeBrowserContextId);
1593 },
1594
1356 - clearAnnotationsForBrowser(browserId, url = null) {
1595 + clearAnnotationsForBrowser(browserId, url = null, contextId = "") {
1596 const numericBrowserId = this.normalizeBrowserId(browserId);
1597 + const normalizedContextId = this.normalizeContextId(contextId || this.activeBrowserContextId);
1598 if (!numericBrowserId) return;
1599 this.annotationComments = this.annotationComments.filter((annotation) => {
1360 - if (!this.sameBrowserId(annotation.browserId, numericBrowserId)) return true;
1600 + if (!this.sameBrowserTab(annotation.browserId, annotation.contextId, numericBrowserId, normalizedContextId)) return true;
1601 return url ? String(annotation.url || "") !== String(url) : false;
1602 });
1603 },
@@ -1510,7 +1750,8 @@ const model = {
1750 },
1751
1752 async createAnnotationDraft(payload, fallbackRect) {
1513 - if (!this.activeBrowserId || !this.contextId) return;
1753 + const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
1754 + if (!this.activeBrowserId || !contextId) return;
1755 const sequence = this._annotationSequence + 1;
1756 const browserId = this.activeBrowserId;
1757 const url = this.activeAnnotationUrl();
@@ -1522,7 +1763,7 @@ const model = {
1763 const response = await websocket.request(
1764 "browser_viewer_annotation",
1765 {
1525 - context_id: this.contextId,
1766 + context_id: contextId,
1767 browser_id: browserId,
1768 viewer_id: this._viewerToken,
1769 payload,
@@ -1535,6 +1776,7 @@ const model = {
1776 this.annotationDraft = {
1777 id: makeViewerToken(),
1778 browserId,
1779 + contextId,
1780 url,
1781 title,
1782 kind: metadata.kind || payload.kind,
@@ -1733,21 +1975,22 @@ const model = {
1975
1976 async syncViewport(force = false, options = {}) {
1977 const restartStream = Boolean(options.restartStream);
1736 - if (!this.contextId || !this.activeBrowserId) {
1978 + const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
1979 + if (!contextId || !this.activeBrowserId) {
1980 return;
1981 }
1982 const viewport = this.currentViewportSize();
1983 if (!viewport) {
1984 return;
1985 }
1743 - const key = `${this.activeBrowserId}:${viewport.width}x${viewport.height}`;
1986 + const key = `${contextId}:${this.activeBrowserId}:${viewport.width}x${viewport.height}`;
1987 if (
1988 (!restartStream && this._lastViewportKey === key)
1989 || (
1990 !force
1991 && !restartStream
1992 && this._lastViewport
1750 - && this.sameBrowserId(this._lastViewport.browserId, this.activeBrowserId)
1993 + && this.sameBrowserTab(this._lastViewport.browserId, this._lastViewport.contextId, this.activeBrowserId, contextId)
1994 && Math.abs(this._lastViewport.width - viewport.width) <= VIEWPORT_SYNC_SIZE_TOLERANCE
1995 && Math.abs(this._lastViewport.height - viewport.height) <= VIEWPORT_SYNC_SIZE_TOLERANCE
1996 )
@@ -1756,7 +1999,7 @@ const model = {
1999 }
2000 try {
2001 await websocket.emit("browser_viewer_input", {
1759 - context_id: this.contextId,
2002 + context_id: contextId,
2003 browser_id: this.activeBrowserId,
2004 viewer_id: this._viewerToken,
2005 input_type: "viewport",
@@ -1767,6 +2010,7 @@ const model = {
2010 this._lastViewportKey = key;
2011 this._lastViewport = {
2012 browserId: this.activeBrowserId,
2013 + contextId,
2014 width: viewport.width,
2015 height: viewport.height,
2016 };
@@ -1779,11 +2023,12 @@ const model = {
2023
2024 async sendMouse(eventType, event) {
2025 if (this.annotating) return;
1782 - if (!this.activeBrowserId || !event?.currentTarget) return;
2026 + const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
2027 + if (!contextId || !this.activeBrowserId || !event?.currentTarget) return;
2028 const pointer = this.pointerCoordinatesFor(event);
2029 if (!pointer) return;
2030 const payload = {
1786 - context_id: this.contextId,
2031 + context_id: contextId,
2032 browser_id: this.activeBrowserId,
2033 viewer_id: this._viewerToken,
2034 input_type: "mouse",
@@ -1807,12 +2052,13 @@ const model = {
2052 },
2053
2054 async sendWheel(event) {
1810 - if (!this.activeBrowserId || !event) return;
2055 + const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
2056 + if (!contextId || !this.activeBrowserId || !event) return;
2057 const image = event.currentTarget?.querySelector?.(".browser-frame") || event.target?.closest?.(".browser-frame");
2058 const pointer = this.pointerCoordinatesFor(event, image);
2059 if (!pointer) return;
2060 const payload = {
1815 - context_id: this.contextId,
2061 + context_id: contextId,
2062 browser_id: this.activeBrowserId,
2063 viewer_id: this._viewerToken,
2064 input_type: "wheel",
@@ -1830,14 +2076,15 @@ const model = {
2076
2077 async sendKey(event) {
2078 if (this.annotating) return;
1833 - if (!this.activeBrowserId) return;
2079 + const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
2080 + if (!contextId || !this.activeBrowserId) return;
2081 if (event.ctrlKey || event.metaKey || event.altKey) return;
2082 const editable = ["INPUT", "TEXTAREA", "SELECT"].includes(event.target?.tagName);
2083 if (editable) return;
2084 event.preventDefault();
2085 const printable = event.key && event.key.length === 1;
2086 await websocket.emit("browser_viewer_input", {
1840 - context_id: this.contextId,
2087 + context_id: contextId,
2088 browser_id: this.activeBrowserId,
2089 input_type: "keyboard",
2090 key: printable ? "" : event.key,
tests/test_browser_agent_regressions.py
+138 -6
@@ -97,6 +97,7 @@ import plugins._browser.helpers.extension_manager as browser_extension_manager_m
97 from plugins._browser.helpers.runtime import (
98 _BrowserRuntimeCore,
99 _BrowserScreencast,
100 + list_runtime_sessions,
101 normalize_url,
102 )
103 import plugins._browser.helpers.runtime as browser_runtime_module
@@ -373,7 +374,11 @@ def test_browser_viewer_creates_chat_when_no_context_is_selected():
374 assert 'import { getContext, setContext } from "/index.js";' in js
375 assert "setContext(contextId)" in js
376 assert "chatsStore.setSelected?.(contextId)" in js
376 - assert "await this.ensureContextId();" in js
377 + assert "this.contextId = existingContextId;" in js
378 + assert "this.contextId = contextId;" in js
379 + assert "let targetContextId = requestedContextId;" in js
380 + assert "targetContextId = await this.ensureContextId();" in js
381 + assert "contextId: targetContextId" in js
382 assert "No active chat context is selected." not in js
383
384
@@ -455,7 +460,7 @@ def test_browser_canvas_restarts_stream_after_page_navigation():
460 assert "await this.restartCanvasStreamAfterPageChange();" in js
461 assert "await this.waitForSurfaceViewport({ sequence: surfaceSequence });" in js
462 assert "await this.syncViewport(true, { restartStream: true });" in js
458 - reconnect_index = js.index("await this.connectViewer({ browserId: this.activeBrowserId });")
463 + reconnect_index = js.index("contextId: this.activeBrowserContextId")
464 restart_index = js.index("await this.restartCanvasStreamAfterPageChange();")
465 assert reconnect_index < restart_index
466
@@ -623,6 +628,18 @@ def test_browser_viewer_uses_tabs_for_session_switching():
628 assert 'class="browser-session-tabs" role="tablist"' in main_html
629 assert 'class="browser-tab"' in main_html
630 assert 'class="browser-new-tab"' in main_html
631 + assert ':key="$store.browserPage.browserTabKey(browser)"' in main_html
632 + assert "browser.context_id" in main_html
633 + assert ':title="$store.browserPage.browserTabTooltip(browser)"' in main_html
634 + assert "browser-tab-context" not in main_html
635 + assert 'handleSelectedContextChange($store.chats?.selected)' in main_html
636 + assert "activeBrowserContextId" in browser_store
637 + assert "sameBrowserTab" in browser_store
638 + assert "applyBrowserListing" in browser_store
639 + assert "browserTabTooltip(browser)" in browser_store
640 + assert "browserChatTitle(browser = {})" in browser_store
641 + assert "contextId.slice" not in browser_store
642 + assert '"browser_viewer_sessions"' in browser_store
643 assert "$store.browserPage.openNewBrowser()" in main_html
644 assert "browser-select" not in main_html
645 assert "browser-live-dot" not in main_html
@@ -644,14 +661,14 @@ def test_browser_tabs_close_without_confirmation_or_busy_lock():
661 close_end = main_html.index("</button>", close_start)
662 close_markup = main_html[close_start:close_end]
663
647 - assert '@click.stop="$store.browserPage.closeBrowser(browser.id)"' in main_html
664 + assert '@click.stop="$store.browserPage.closeBrowser(browser.id, browser.context_id)"' in main_html
665 assert "@pointerdown.stop" in close_markup
666 assert "$confirmClick" not in main_html
667 assert "isBusy()" not in close_markup
651 - assert ':disabled="$store.browserPage.isClosingBrowser(browser.id)"' in close_markup
668 + assert ':disabled="$store.browserPage.isClosingBrowser(browser.id, browser.context_id)"' in close_markup
669 assert "--browser-tab-close-size: 32px;" in main_html
653 - assert "async closeBrowser(id)" in browser_store
654 - assert "isClosingBrowser(id)" in browser_store
670 + assert "async closeBrowser(id, contextId = \"\")" in browser_store
671 + assert "isClosingBrowser(id, contextId = \"\")" in browser_store
672 assert "_closingBrowserIds" in browser_store
673 assert "_commandInFlightCount" in browser_store
674
@@ -1088,6 +1105,121 @@ async def test_browser_viewer_subscribe_unregisters_stream(monkeypatch):
1105 assert ("sid-1", "ctx") not in ws_browser_module.WsBrowser._streams
1106
1107
1108 +@pytest.mark.anyio
1109 +async def test_browser_runtime_sessions_are_context_qualified(monkeypatch):
1110 + class FakeRuntime:
1111 + async def call(self, method, *args, **kwargs):
1112 + assert method == "list"
1113 + return {
1114 + "browsers": [{"id": 1, "context_id": "ctx-a", "currentUrl": "https://example.com/"}],
1115 + "last_interacted_browser_id": 1,
1116 + }
1117 +
1118 + with browser_runtime_module._runtime_lock:
1119 + previous_runtimes = dict(browser_runtime_module._runtimes)
1120 + browser_runtime_module._runtimes.clear()
1121 + browser_runtime_module._runtimes["ctx-a"] = FakeRuntime()
1122 + try:
1123 + sessions = await list_runtime_sessions()
1124 + finally:
1125 + with browser_runtime_module._runtime_lock:
1126 + browser_runtime_module._runtimes.clear()
1127 + browser_runtime_module._runtimes.update(previous_runtimes)
1128 +
1129 + assert sessions == [
1130 + {
1131 + "context_id": "ctx-a",
1132 + "browsers": [{"id": 1, "context_id": "ctx-a", "currentUrl": "https://example.com/"}],
1133 + "last_interacted_browser_id": 1,
1134 + }
1135 + ]
1136 +
1137 +
1138 +@pytest.mark.anyio
1139 +async def test_browser_viewer_command_returns_tabs_from_all_contexts(monkeypatch):
1140 + class FakeRuntime:
1141 + async def call(self, method, *args, **kwargs):
1142 + if method == "list":
1143 + return {
1144 + "browsers": [{"id": 1, "context_id": "ctx-a", "currentUrl": "about:blank"}],
1145 + "last_interacted_browser_id": 1,
1146 + }
1147 + raise AssertionError(method)
1148 +
1149 + async def fake_get_runtime(context_id, create=True):
1150 + assert context_id == "ctx-a"
1151 + return FakeRuntime()
1152 +
1153 + async def fake_list_runtime_sessions():
1154 + return [
1155 + {
1156 + "context_id": "ctx-a",
1157 + "browsers": [{"id": 1, "context_id": "ctx-a", "currentUrl": "about:blank"}],
1158 + "last_interacted_browser_id": 1,
1159 + },
1160 + {
1161 + "context_id": "ctx-b",
1162 + "browsers": [{"id": 1, "context_id": "ctx-b", "currentUrl": "https://example.org/"}],
1163 + "last_interacted_browser_id": 1,
1164 + },
1165 + ]
1166 +
1167 + monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
1168 + monkeypatch.setattr(ws_browser_module, "list_runtime_sessions", fake_list_runtime_sessions)
1169 +
1170 + handler = ws_browser_module.WsBrowser(
1171 + SimpleNamespace(),
1172 + threading.RLock(),
1173 + manager=None,
1174 + )
1175 +
1176 + result = await handler.process(
1177 + "browser_viewer_command",
1178 + {"context_id": "ctx-a", "command": "list"},
1179 + "sid-1",
1180 + )
1181 +
1182 + assert result["all_browsers"] is True
1183 + assert result["active_browser_context_id"] == "ctx-a"
1184 + assert [browser["context_id"] for browser in result["browsers"]] == ["ctx-a", "ctx-b"]
1185 +
1186 +
1187 +@pytest.mark.anyio
1188 +async def test_browser_viewer_sessions_lists_without_creating_runtime(monkeypatch):
1189 + async def fake_list_runtime_sessions():
1190 + return [
1191 + {
1192 + "context_id": "ctx-a",
1193 + "browsers": [{"id": 1, "context_id": "ctx-a", "currentUrl": "about:blank"}],
1194 + "last_interacted_browser_id": 1,
1195 + }
1196 + ]
1197 +
1198 + async def fail_get_runtime(*args, **kwargs):
1199 + raise AssertionError("sessions refresh must not create or fetch one runtime")
1200 +
1201 + monkeypatch.setattr(ws_browser_module, "list_runtime_sessions", fake_list_runtime_sessions)
1202 + monkeypatch.setattr(ws_browser_module, "get_runtime", fail_get_runtime)
1203 +
1204 + handler = ws_browser_module.WsBrowser(
1205 + SimpleNamespace(),
1206 + threading.RLock(),
1207 + manager=None,
1208 + )
1209 +
1210 + result = await handler.process(
1211 + "browser_viewer_sessions",
1212 + {"context_id": "ctx-b"},
1213 + "sid-1",
1214 + )
1215 +
1216 + assert result == {
1217 + "context_id": "ctx-b",
1218 + "browsers": [{"id": 1, "context_id": "ctx-a", "currentUrl": "about:blank"}],
1219 + "all_browsers": True,
1220 + }
1221 +
1222 +
1223 @pytest.mark.anyio
1224 async def test_browser_viewer_viewport_input_dispatches_resize(monkeypatch):
1225 calls = []